UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

1,030 lines (989 loc) 39.3 kB
import { hasCommercialLicense, onLicenseCheckResultChanged } from "../../engine_license.js"; import { isLocalNetwork } from "../../engine_networking_utils.js"; import { DeviceUtilities, getParam } from "../../engine_utils.js"; import { onXRSessionStart } from "../../xr/events.js"; import { ButtonsFactory } from "../buttons.js"; import { ensureFonts, iconFontUrl, loadFont } from "../fonts.js"; import { getIconElement } from "../icons.js"; import { NeedleLogoElement } from "../logo-element.js"; import { NeedleSpatialMenu } from "./needle-menu-spatial.js"; const elementName = "needle-menu"; const debug = getParam("debugmenu"); const debugNonCommercial = getParam("debugnoncommercial"); /** * The NeedleMenu is a menu that can be displayed in the needle engine webcomponent or in VR/AR sessions. * The menu can be used to add buttons to the needle engine that can be used to interact with the application. * The menu can be positioned at the top or the bottom of the needle engine webcomponent * * @example Create a button using the NeedleMenu * ```typescript * onStart(ctx => { * ctx.menu.appendChild({ * label: "Open Google", * icon: "google", * onClick: () => { window.open("https://www.google.com", "_blank") } * }); * }) * ``` * * Buttons can be added to the menu using the {@link NeedleMenu#appendChild} method or by sending a postMessage event to the needle engine with the type "needle:menu". Use the {@link NeedleMenuPostMessageModel} model to create buttons with postMessage. * @example Create a button using a postmessage * ```javascript * window.postMessage({ * type: "needle:menu", * button: { * label: "Open Google", * icon: "google", * onclick: "https://www.google.com", * target: "_blank", * } * }, "*"); * ``` */ export class NeedleMenu { _context; _menu; _spatialMenu; constructor(context) { this._menu = NeedleMenuElement.getOrCreate(context.domElement, context); this._context = context; this._spatialMenu = new NeedleSpatialMenu(context, this._menu); window.addEventListener("message", this.onPostMessage); onXRSessionStart(this.onStartXR); } /** @ignore internal method */ onDestroy() { window.removeEventListener("message", this.onPostMessage); this._menu.remove(); this._spatialMenu.onDestroy(); } onPostMessage = (e) => { // lets just allow the same origin for now if (e.origin !== globalThis.location.origin) return; if (typeof e.data === "object") { const data = e.data; const type = data.type; if (type === "needle:menu") { const buttoninfo = data.button; if (buttoninfo) { if (!buttoninfo.label) return console.error("NeedleMenu: buttoninfo.label is required"); if (!buttoninfo.onclick) return console.error("NeedleMenu: buttoninfo.onclick is required"); const button = document.createElement("button"); button.textContent = buttoninfo.label; if (buttoninfo.icon) { const icon = getIconElement(buttoninfo.icon); button.prepend(icon); } if (buttoninfo.priority) { button.setAttribute("priority", buttoninfo.priority.toString()); } button.onclick = () => { if (buttoninfo.onclick) { const isLink = buttoninfo.onclick.startsWith("http") || buttoninfo.onclick.startsWith("www."); const target = buttoninfo.target || "_blank"; if (isLink) { globalThis.open(buttoninfo.onclick, target); } else console.error("NeedleMenu: onclick is not a valid link", buttoninfo.onclick); } }; this._menu.appendChild(button); } else if (debug) console.error("NeedleMenu: unknown postMessage event", data); } else if (debug) console.warn("NeedleMenu: unknown postMessage type", type, data); } }; onStartXR = (args) => { if (args.session.isScreenBasedAR) { this._menu["previousParent"] = this._menu.parentNode; this._context.arOverlayElement.appendChild(this._menu); args.session.session.addEventListener("end", this.onExitXR); // Close the foldout if it's open on entering AR this._menu.closeFoldout(); } }; onExitXR = () => { if (this._menu["previousParent"]) { this._menu["previousParent"].appendChild(this._menu); delete this._menu["previousParent"]; } }; /** Experimental: Change the menu position to be at the top or the bottom of the needle engine webcomponent * @param position "top" or "bottom" */ setPosition(position) { this._menu.setPosition(position); } /** * Call to show or hide the menu. * NOTE: Hiding the menu is a PRO feature and requires a needle engine license. Hiding the menu will not work in production without a license. */ setVisible(visible) { this._menu.setVisible(visible); } /** When set to false, the Needle Engine logo will be hidden. Hiding the logo requires a needle engine license */ showNeedleLogo(visible) { this._menu.showNeedleLogo(visible); this._spatialMenu?.showNeedleLogo(visible); // setTimeout(()=>this.showNeedleLogo(!visible), 1000); } /** @returns true if the logo is visible */ get logoIsVisible() { return this._menu.logoIsVisible; } /** When enabled=true the menu will be visible in VR/AR sessions */ showSpatialMenu(enabled) { this._spatialMenu.setEnabled(enabled); } setSpatialMenuVisible(display) { this._spatialMenu.setDisplay(display); } get spatialMenuIsVisible() { return this._spatialMenu.isVisible; } /** * Call to add or remove a button to the menu to show a QR code for the current page * If enabled=true then a button will be added to the menu that will show a QR code for the current page when clicked. */ showQRCodeButton(enabled) { if (enabled === "desktop-only") { enabled = !DeviceUtilities.isMobileDevice(); } if (!enabled) { const button = ButtonsFactory.getOrCreate().qrButton; if (button) button.style.display = "none"; return button ?? null; } else { const button = ButtonsFactory.getOrCreate().createQRCode(); button.style.display = ""; this._menu.appendChild(button); return button; } } /** Call to add or remove a button to the menu to mute or unmute the application * Clicking the button will mute or unmute the application */ showAudioPlaybackOption(visible) { if (!visible) { this._muteButton?.remove(); return; } this._muteButton = ButtonsFactory.getOrCreate().createMuteButton(this._context); this._muteButton.setAttribute("priority", "100"); this._menu.appendChild(this._muteButton); } _muteButton; showFullscreenOption(visible) { if (!visible) { this._fullscreenButton?.remove(); return; } this._fullscreenButton = ButtonsFactory.getOrCreate().createFullscreenButton(this._context); if (this._fullscreenButton) { this._fullscreenButton.setAttribute("priority", "150"); this._menu.appendChild(this._fullscreenButton); } } _fullscreenButton; appendChild(child) { return this._menu.appendChild(child); } } export class NeedleMenuElement extends HTMLElement { static create() { // https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement#is return document.createElement(elementName, { is: elementName }); } static getOrCreate(domElement, context) { let element = domElement.querySelector(elementName); if (!element && domElement.shadowRoot) { element = domElement.shadowRoot.querySelector(elementName); } // if no needle-menu was found in the domelement then we search the document body if (!element) { element = window.document.body.querySelector(elementName); } if (!element) { // OK no menu element exists yet anywhere element = NeedleMenuElement.create(); if (domElement.shadowRoot) domElement.shadowRoot.appendChild(element); else domElement.appendChild(element); } element._domElement = domElement; element._context = context; return element; } _domElement = null; _context = null; constructor() { super(); const template = document.createElement('template'); // TODO: make host full size again and move the buttons to a wrapper so that we can later easily open e.g. foldouts/dropdowns / use the whole canvas space template.innerHTML = `<style> #root { position: absolute; width: auto; max-width: 95%; left: 50%; transform: translateX(-50%); top: min(20px, 10vh); padding: 0.3rem; display: flex; visibility: visible; flex-direction: row-reverse; /* if we overflow this should be right aligned so the logo is always visible */ pointer-events: all; z-index: 1000; } /** hide the menu if it's empty **/ #root.has-no-options.logo-hidden { display: none; } /** using a div here because then we can change the class for placement **/ #root.bottom { top: auto; bottom: min(30px, 10vh); } #root.top { top: calc(.7rem + env(safe-area-inset-top)); } .wrapper { position: relative; display: flex; flex-direction: row; justify-content: center; align-items: stretch; gap: 0px; padding: 0 0rem; } .wrapper > *, .options > button, .options > select, ::slotted(*) { position: relative; border: none; border-radius: 0; outline: 1px solid rgba(0,0,0,0); display: flex; justify-content: center; align-items: center; max-height: 2.3rem; max-width: 100%; /** basic font settings for all entries **/ font-size: 1rem; font-family: 'Roboto Flex', sans-serif; font-optical-sizing: auto; font-weight: 500; font-weight: 200; font-variation-settings: "wdth" 100; color: rgb(20,20,20); } .options > select[multiple]:hover { max-height: 300px; } .floating-panel-style { background: rgba(255, 255, 255, .4); outline: rgb(0 0 0 / 5%) 1px solid; border: 1px solid rgba(255, 255, 255, .1); box-shadow: 0px 7px 0.5rem 0px rgb(0 0 0 / 6%), inset 0px 0px 1.3rem rgba(0,0,0,.05); border-radius: 1.5rem; /** * to make nested background filter work * https://stackoverflow.com/questions/60997948/backdrop-filter-not-working-for-nested-elements-in-chrome **/ &::before { content: ''; position: absolute; width: 100%; height: 100%; top: 0; left: 0; z-index: -1; border-radius: 1.5rem; -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); } } a { color: inherit; text-decoration: none; } .options { display: flex; flex-direction: row; align-items: center; } .options > *, ::slotted(*) { max-height: 2.25rem; padding: .4rem .5rem; } :host .options > *, ::slotted(*) { background: transparent; border: none; white-space: nowrap; transition: all 0.1s linear .02s; border-radius: 1.5rem; user-select: none; } :host .options > *:hover, ::slotted(*:hover) { cursor: pointer; color: black; background: rgba(245, 245, 245, .8); box-shadow: inset 0 0 1rem rgba(0,0,30,.2); outline: rgba(0,0,0,.1) 1px solid; } :host .options > *:active, ::slotted(*:active) { background: rgba(255, 255, 255, .8); box-shadow: inset 0px 1px 1px rgba(255,255,255,.5), inset 0 0 2rem rgba(0,0,30,.2), inset 0px 2px 4px rgba(0,0,20,.5); transition: all 0.05s linear; } :host .options > *:focus, ::slotted(*:focus) { outline: rgba(255,255,255,.5) 1px solid; } :host .options > *:focus-visible, ::slotted(*:focus-visible) { outline: rgba(0,0,0,.5) 1px solid; } :host .options > *:disabled, ::slotted(*:disabled) { background: rgba(0,0,0,.05); color: rgba(60,60,60,.7); pointer-events: none; } button, ::slotted(button) { gap: 0.3rem; } /** XR button animation **/ :host button.this-mode-is-requested { background: repeating-linear-gradient(to right, #fff 0%, #fff 40%, #aaffff 55%, #fff 80%); background-size: 200% auto; background-position: 0 100%; animation: AnimationName .7s ease infinite forwards; } :host button.other-mode-is-requested { opacity: .5; } @keyframes AnimationName { 0% { background-position: 0% 0 } 100% { background-position: -200% 0 } } .logo { cursor: pointer; padding-left: 0.6rem; padding-bottom: .02rem; margin-right: 0.5rem; } .logo-hidden { .logo { display: none; } } :host .has-options .logo { border-left: 1px solid rgba(40,40,40,.4); margin-left: 0.3rem; margin-right: 0.5rem; } .logo > span { white-space: nowrap; } /** COMPACT */ /** Hide the menu button normally **/ .compact-menu-button { display: none; } /** And show it when we're in compact mode **/ .compact .compact-menu-button { position: relative; display: block; background: none; border: none; border-radius: 2rem; margin: 0; padding: 0 .3rem; padding-top: .2rem; z-index: 100; color: #000; &:hover { background: rgba(255,255,255,.2); cursor: pointer; } &:focus { outline: 1px solid rgba(255,255,255,.5); } &:focus-visible { outline: 1px solid rgba(0,0,0,.5); } & .expanded-click-area { position: absolute; left: 0; right: 0; top: 10%; bottom: 10%; transform: scale(1.8); } } .has-no-options .compact-menu-button { display: none; } .open .compact-menu-button { background: rgba(255,255,255,.2); } .logo-visible .compact-menu-button { margin-left: .2rem; } /** Open and hide menu **/ .compact .foldout { display: none; } .open .options, .open .foldout { display: flex; justify-content: center; } .compact .wrapper { padding: 0; } .compact .wrapper, .compact .options { height: auto; max-height: initial; flex-direction: row; gap: .12rem; } .compact .options { flex-wrap: wrap; gap: .3rem; } .compact .top .options { height: auto; flex-direction: row; } .compact .bottom .wrapper { height: auto; flex-direction: column; } .compact .foldout { max-height: min(100ch, calc(100vh - 100px)); overflow: auto; overflow-x: hidden; align-items: center; position: fixed; bottom: calc(100% + 5px); z-index: 100; width: auto; left: .2rem; right: .2rem; padding: .2rem; } .compact.logo-hidden .foldout { /** for when there's no logo we want to center the foldout **/ min-width: 24ch; margin-left: 50%; transform: translateX(calc(-50% - .2rem)); } .compact.top .foldout { top: calc(100% + 5px); bottom: auto; } ::-webkit-scrollbar { max-width: 7px; background: rgba(100,100,100,.2); border-radius: .2rem; } ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .3); border-radius: .2rem; } ::-webkit-scrollbar-thumb:hover { background: rgb(150,150,150); } .compact .options > *, .compact .options > ::slotted(*) { font-size: 1.2rem; padding: .6rem .5rem; width: 100%; } .compact.has-options .logo { border: none; padding-left: 0; margin-left: 1rem; margin-bottom: .02rem; } .compact .options { /** e.g. if we have a very wide menu item like a select with long option names we don't want to overflow **/ max-width: 100%; & > button, & > select { display: flex; flex-basis: 100%; min-height: 3rem; } & > button.row2 { //border: 1px solid red !important; display: flex; flex: 1; flex-basis: 30%; } } /** If there's really not enough space then just hide all options **/ @media (max-width: 100px) or (max-height: 100px){ .foldout { display: none !important; } .compact-menu-button { display: none !important; } } /* dark mode */ /* @media (prefers-color-scheme: dark) { :host { background: rgba(0,0,0, .6); } :host button { color: rgba(200,200,200); } :host button:hover { background: rgba(100,100,100, .8); } } */ </style> <div id="root" class="logo-hidden floating-panel-style bottom"> <div class="wrapper"> <div class="foldout"> <div class="options" part="options"> <slot></slot> </div> <div class="options" part="options"> <slot name="end"></slot> </div> </div> <div style="user-select:none" class="logo"> <span class="madewith notranslate">powered by</span> </div> </div> <button class="compact-menu-button"> <div class="expanded-click-area"></div> </button> </div> `; // we dont need to expose the shadow root const shadow = this.attachShadow({ mode: 'open' }); // we need to add the icons to both the shadow dom as well as the HEAD to work // https://github.com/google/material-design-icons/issues/1165 ensureFonts(); loadFont(iconFontUrl, { loadedCallback: () => { this.handleSizeChange(); } }); loadFont(iconFontUrl, { element: shadow }); const content = template.content.cloneNode(true); shadow?.appendChild(content); this.root = shadow.querySelector("#root"); this.wrapper = this.root?.querySelector(".wrapper"); this.options = this.root?.querySelector(".options"); this.logoContainer = this.root?.querySelector(".logo"); this.compactMenuButton = this.root?.querySelector(".compact-menu-button"); this.compactMenuButton.append(getIconElement("more_vert")); this.foldout = this.root?.querySelector(".foldout"); this.root?.appendChild(this.wrapper); this.wrapper.classList.add("wrapper"); const logo = NeedleLogoElement.create(); logo.style.minHeight = "1rem"; this.logoContainer.append(logo); this.logoContainer.addEventListener("click", () => { globalThis.open("https://needle.tools", "_blank"); }); try { // if the user has a license then we CAN hide the needle logo // calling this method immediately will cause an issue with vite bundling tho window.requestAnimationFrame(() => onLicenseCheckResultChanged(res => { if (res == true && hasCommercialLicense() && !debugNonCommercial) { let visible = this._userRequestedLogoVisible; if (visible === undefined) visible = false; this.___onSetLogoVisible(visible); } else { this.___onSetLogoVisible(true); } })); } catch (e) { console.error("[Needle Menu] License check failed.", e); } this.compactMenuButton.addEventListener("click", evt => { evt.preventDefault(); this.root.classList.toggle("open"); }); let context = this._context; // we need to assign it in the timeout because the reference is set *after* the constructor did run setTimeout(() => context = this._context); // watch changes let changeEventCounter = 0; const forceVisible = (parent, visible) => { if (debug) console.log("Set menu visible", visible); if (context?.isInAR && context.arOverlayElement) { if (parent != context.arOverlayElement) { context.arOverlayElement.appendChild(this); } } else if (this.parentNode != this._domElement?.shadowRoot) this._domElement?.shadowRoot?.appendChild(this); this.style.display = visible ? "flex" : "none"; this.style.visibility = "visible"; this.style.opacity = "1"; }; let isHandlingMutation = false; const rootObserver = new MutationObserver(mutations => { if (isHandlingMutation) { return; } try { isHandlingMutation = true; this.onChangeDetected(mutations); // ensure the menu is not hidden or removed const requiredParent = this?.parentNode; if (this.style.display != "flex" || this.style.visibility != "visible" || this.style.opacity != "1" || requiredParent != this._domElement?.shadowRoot) { if (!hasCommercialLicense()) { const change = changeEventCounter++; // if a user doesn't have a local pro license *but* for development the menu is hidden then we show a warning if (isLocalNetwork() && this._userRequestedMenuVisible === false) { // set visible once so that the check above is not triggered again if (change === 0) { // if the user requested visible to false before this code is called for the first time we want to respect the choice just in this case forceVisible(requiredParent, this._userRequestedMenuVisible); } // warn only once if (change === 1) { console.warn(`Needle Menu Warning: You need a PRO license to hide the Needle Engine menu → The menu will be visible in your deployed website if you don't have a PRO license. See https://needle.tools/pricing for details.`); } } else { if (change === 0) { forceVisible(requiredParent, true); } else { setTimeout(() => forceVisible(requiredParent, true), 5); } } } } } finally { isHandlingMutation = false; } }); rootObserver.observe(this.root, { childList: true, subtree: true, attributes: true }); if (debug) { this.___insertDebugOptions(); } } _sizeChangeInterval; connectedCallback() { window.addEventListener("resize", this.handleSizeChange); this.handleMenuVisible(); this._sizeChangeInterval = setInterval(() => this.handleSizeChange(undefined, true), 5000); // the dom element is set after the constructor runs setTimeout(() => { this._domElement?.addEventListener("resize", this.handleSizeChange); this._domElement?.addEventListener("click", this.#onClick); }, 1); } disconnectedCallback() { window.removeEventListener("resize", this.handleSizeChange); clearInterval(this._sizeChangeInterval); this._domElement?.removeEventListener("resize", this.handleSizeChange); this._context?.domElement.removeEventListener("click", this.#onClick); } #onClick = (e) => { // detect a click outside the opened foldout to automatically close it if (!e.defaultPrevented && e.target == this._domElement && (e instanceof PointerEvent && e.button === 0) && this.root.classList.contains("open")) { // The menu is open, it's a click outside the foldout? const rect = this.foldout.getBoundingClientRect(); const pointerEvent = e; if (!(pointerEvent.clientX > rect.left && pointerEvent.clientX < rect.right && pointerEvent.clientY > rect.top && pointerEvent.clientY < rect.bottom)) { this.root.classList.toggle("open", false); } } }; _userRequestedLogoVisible = undefined; showNeedleLogo(visible) { this._userRequestedLogoVisible = visible; if (!visible) { if (!hasCommercialLicense() || debugNonCommercial) { console.warn("[Needle Engine] You need a PRO license to hide the Needle Engine logo in production."); const localNetwork = isLocalNetwork(); if (!localNetwork) return; } } this.___onSetLogoVisible(visible); } /** @returns true if the logo is visible */ get logoIsVisible() { return !this.root.classList.contains("logo-hidden"); } ___onSetLogoVisible(visible) { this.logoContainer.style.display = ""; this.logoContainer.style.opacity = "1"; this.logoContainer.style.visibility = "visible"; if (visible) { this.root.classList.remove("logo-hidden"); this.root.classList.add("logo-visible"); } else { this.root.classList.remove("logo-visible"); this.root.classList.add("logo-hidden"); } } setPosition(position) { // ensure the position is of a known type: if (position !== "top" && position !== "bottom") { return console.error("NeedleMenu.setPosition: invalid position", position); } this.root.classList.remove("top", "bottom"); this.root.classList.add(position); } _userRequestedMenuVisible = undefined; setVisible(visible) { this._userRequestedMenuVisible = visible; this.style.display = visible ? "flex" : "none"; } /** * If the menu is in compact mode and the foldout is currently open (to show all menu options) then this will close the foldout */ closeFoldout() { this.root.classList.remove("open"); } // private _root: ShadowRoot | null = null; root; /** wraps the whole content */ wrapper; /** contains the buttons and dynamic elements */ options; /** contains the needle-logo html element */ logoContainer; compactMenuButton; foldout; append(...nodes) { for (const node of nodes) { if (typeof node === "string") { const element = document.createTextNode(node); this.options.appendChild(element); } else { this.options.appendChild(node); } } } appendChild(node) { if (!(node instanceof Node)) { const button = document.createElement("button"); button.textContent = node.label; button.onclick = node.onClick; button.setAttribute("priority", node.priority?.toString() ?? "0"); if (node.title) { button.title = node.title; } if (node.icon) { const icon = getIconElement(node.icon); if (node.iconSide === "right") { button.appendChild(icon); } else { button.prepend(icon); } } if (node.class) { button.classList.add(node.class); } node = button; } const res = this.options.appendChild(node); return res; } prepend(...nodes) { for (const node of nodes) { if (typeof node === "string") { const element = document.createTextNode(node); this.options.prepend(element); } else { this.options.prepend(node); } } } _isHandlingChange = false; /** Called when any change in the web component is detected (including in children and child attributes) */ onChangeDetected(_mut) { if (this._isHandlingChange) return; this._isHandlingChange = true; try { // if (debug) console.log("NeedleMenu.onChangeDetected", _mut); this.handleMenuVisible(); for (const mut of _mut) { if (mut.target == this.options) { this.onOptionsChildrenChanged(mut); } } } finally { this._isHandlingChange = false; } } onOptionsChildrenChanged(_mut) { this.root.classList.toggle("has-options", this.hasAnyVisibleOptions); this.root.classList.toggle("has-no-options", !this.hasAnyVisibleOptions); this.handleSizeChange(undefined, true); if (_mut.type === "childList") { if (_mut.addedNodes.length > 0) { const children = Array.from(this.options.children); children.sort((a, b) => { const p1 = parseInt(a.getAttribute("priority") || "0"); const p2 = parseInt(b.getAttribute("priority") || "0"); return p1 - p2; }); let sortingChanged = false; for (let i = 0; i < children.length; i++) { const existing = this.options.children[i]; const child = children[i]; if (existing !== child) { sortingChanged = true; break; } } if (sortingChanged) { for (const child of children) { this.options.appendChild(child); } } } } } _didSort = new Map(); /** checks if the menu has any content and should be rendered at all * if we dont have any content and logo then we hide the menu */ handleMenuVisible() { if (debug) console.log("Update VisibleState: Any Content?", this.hasAnyContent); if (this.hasAnyContent) { this.root.style.display = ""; } else { this.root.style.display = "none"; } this.root.classList.toggle("has-options", this.hasAnyVisibleOptions); this.root.classList.toggle("has-no-options", !this.hasAnyVisibleOptions); } /** @returns true if we have any content OR a logo */ get hasAnyContent() { // is the logo visible? if (this.logoContainer.style.display != "none") return true; if (this.hasAnyVisibleOptions) return true; return false; } get hasAnyVisibleOptions() { // do we have any visible buttons? for (let i = 0; i < this.options.children.length; i++) { const child = this.options.children[i]; // is slot? if (child.tagName === "SLOT") { const slotElement = child; const nodes = slotElement.assignedNodes(); for (const node of nodes) { if (node instanceof HTMLElement) { if (node.style.display != "none") return true; } } } else if (child.style.display != "none") return true; } return false; } _lastAvailableWidthChange = 0; _timeoutHandle = 0; handleSizeChange = (_evt, forceOrEvent) => { if (!this._domElement) return; const width = this._domElement.clientWidth; if (width < 100) { clearTimeout(this._timeoutHandle); this.root.classList.add("compact"); this.foldout.classList.add("floating-panel-style"); return; } const padding = 20 * 2; const availableWidth = width - padding; // if the available width has not changed significantly then we can skip the rest if (!forceOrEvent && Math.abs(availableWidth - this._lastAvailableWidthChange) < 1) return; this._lastAvailableWidthChange = availableWidth; clearTimeout(this._timeoutHandle); this._timeoutHandle = setTimeout(() => { const spaceLeft = getSpaceLeft(); if (spaceLeft < 0) { this.root.classList.add("compact"); this.foldout.classList.add("floating-panel-style"); } else if (spaceLeft > 0) { this.root.classList.remove("compact"); this.foldout.classList.remove("floating-panel-style"); if (getSpaceLeft() < 0) { // ensure we still have enough space left this.root.classList.add("compact"); this.foldout.classList.add("floating-panel-style"); } } }, 5); const getCurrentWidth = () => { return this.options.clientWidth + this.logoContainer.clientWidth; }; const getSpaceLeft = () => { return availableWidth - getCurrentWidth(); }; }; ___insertDebugOptions() { window.addEventListener("keydown", (e) => { if (e.key === "p") { this.setPosition(this.root.classList.contains("top") ? "bottom" : "top"); } }); const removeOptionsButton = document.createElement("button"); removeOptionsButton.textContent = "Hide Buttons"; removeOptionsButton.onclick = () => { const optionsChildren = new Array(this.options.children.length); for (let i = 0; i < this.options.children.length; i++) { optionsChildren[i] = this.options.children[i]; } for (const child of optionsChildren) { this.options.removeChild(child); } setTimeout(() => { for (const child of optionsChildren) { this.options.appendChild(child); } }, 1000); }; this.appendChild(removeOptionsButton); const anotherButton = document.createElement("button"); anotherButton.textContent = "Toggle Logo"; anotherButton.addEventListener("click", () => { this.logoContainer.style.display = this.logoContainer.style.display === "none" ? "" : "none"; }); this.appendChild(anotherButton); } } if (!customElements.get(elementName)) customElements.define(elementName, NeedleMenuElement); //# sourceMappingURL=needle-menu.js.map