@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
JavaScript
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 ;
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 ;
}
.compact-menu-button {
display: none ;
}
}
/* 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