@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,332 lines (1,193 loc) • 50.4 kB
text/typescript
import { showBalloonMessage } from "../../debug/debug.js";
import type { Context } from "../../engine_context.js";
import { hasCommercialLicense, onLicenseCheckResultChanged, Telemetry } from "../../engine_license.js";
import { isLocalNetwork } from "../../engine_networking_utils.js";
import { DeviceUtilities, getParam } from "../../engine_utils.js";
import { onXRSessionStart, XRSessionEventArgs } 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";
declare global {
interface HTMLElementTagNameMap {
"needle-logo-element": NeedleLogoElement;
}
}
const elementName = "needle-menu";
const debug = getParam("debugmenu");
const debugNonCommercial = getParam("debugnoncommercial");
/** This is the model for the postMessage event that the needle engine will send to create menu items */
export declare type NeedleMenuPostMessageModel = {
type: "needle:menu",
button?: {
label?: string,
/** Google icon name */
icon?: string,
/** currently only URLs are supported */
onclick?: string,
target?: "_blank" | "_self" | "_parent" | "_top",
/** Low priority is icon is on the left, high priority is icon is on the right. Default is 0 */
priority?: number,
}
}
/**
* Used by the NeedleMenuElement to create a button at {@link NeedleMenuElement#appendChild}
*/
export declare type ButtonInfo = {
/** Invoked when the button is clicked */
onClick: (evt: Event) => void,
/** Visible button text */
label: string,
/** Material icon name: https://fonts.google.com/icons */
icon?: string,
/** "left" or "right" to place the icon on the left or right side of the button. Default is "left" */
iconSide?: "left" | "right",
/**
* Priority controls the order of buttons in the menu.
* If not enough space is available to show all buttons - the highest priority elements will always be visible
*
* **Sorting**
* Low priority is icon is on the left,
* high priority is icon is on the right.
* @default undefined
*/
priority?: number;
/** Experimental. Allows to put two buttons in one row for the compact layout */
class?: "row2";
title?: string;
}
/**
* 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 Add a new 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",
* }
* }, "*");
* ```
*
* @example Access the menu from a component
* ```typescript
* import { Behaviour, OnStart } from '@needle-tools/engine';
*
* export class MyComponent extends Behaviour {
*
* start() {
* this.context.menu.appendChild({ ... });
* }
* }
* ```
*
* @category HTML
*/
export class NeedleMenu {
static setElementPriority(button: HTMLElement, priority: number) {
button.setAttribute("priority", String(priority));
}
static getElementPriority(button: HTMLElement): number | undefined {
const priority = button.getAttribute("priority");
if (priority) {
const val = Number.parseFloat(priority);
if (!Number.isNaN(val)) return val;
}
return undefined;
}
private readonly _context: Context;
private readonly _menu: NeedleMenuElement;
private readonly _spatialMenu: NeedleSpatialMenu;
constructor(context: Context) {
this._menu = NeedleMenuElement.getOrCreate(context.domElement, context);
this._menu.ensureInitialized();
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();
}
private onPostMessage = (e: MessageEvent) => {
// lets just allow the same origin for now
if (e.origin !== globalThis.location.origin) return;
if (typeof e.data === "object") {
const data = e.data as NeedleMenuPostMessageModel;
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);
}
}
Telemetry.sendEvent(this._context, "needle-menu", {
action: "button_added_via_postmessage",
});
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);
}
};
private onStartXR = (args: XRSessionEventArgs) => {
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();
}
}
private 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: "top" | "bottom") {
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: boolean) {
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: boolean) {
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: boolean) {
this._spatialMenu.setEnabled(enabled);
}
setSpatialMenuVisible(display: boolean) {
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: boolean | "desktop-only"): HTMLButtonElement | null {
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: boolean): void {
if (!visible) {
this._muteButton?.remove();
return;
}
this._muteButton = ButtonsFactory.getOrCreate().createMuteButton(this._context);
this._menu.appendChild(this._muteButton);
}
private _muteButton?: HTMLButtonElement;
showFullscreenOption(visible: boolean): void {
if (!visible) {
this._fullscreenButton?.remove();
return;
}
this._fullscreenButton = ButtonsFactory.getOrCreate().createFullscreenButton(this._context);
if (this._fullscreenButton) {
this._menu.appendChild(this._fullscreenButton);
}
}
private _fullscreenButton?: HTMLButtonElement | null;
appendChild(child: HTMLElement | ButtonInfo) {
return this._menu.appendChild(child);
}
}
// #region Web component
/**
* `<needle-menu>` web component — lightweight menu used by Needle Engine.
*
* This element is intended as an internal UI primitive for hosting application
* menus and buttons. Use the higher-level `NeedleMenu` API from the engine
* code to manipulate it programmatically. Public DOM-facing methods are
* documented (appendChild / append / prepend / setPosition / setVisible).
*
* @element needle-menu
*/
export class NeedleMenuElement extends HTMLElement {
static create() {
// https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement#is
return document.createElement(elementName);
}
static getOrCreate(domElement: HTMLElement, context: Context) {
let element = domElement.querySelector(elementName) as NeedleMenuElement | null;
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) as NeedleMenuElement | null;
}
if (!element) {
// OK no menu element exists yet anywhere
element = NeedleMenuElement.create() as NeedleMenuElement;
if (domElement.shadowRoot)
domElement.shadowRoot.appendChild(element);
else
domElement.appendChild(element);
}
element._domElement = domElement;
element._context = context;
return element as NeedleMenuElement;
}
private _domElement: HTMLElement | null = null;
private _context: Context | null = null;
private _didInitialize = false;
constructor() {
super();
}
private initializeDom() {
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>
/** Styling attributes that ensure the nested menu z-index does not cause it to overlay elements outside of <needle-engine> */
:host {
position: absolute;
width: 100%;
height: 100%;
z-index: 0;
top: 0;
pointer-events: none;
}
/** we put base styles in a layer to allow overrides more easily (e.g. the button.mode requested animation should override the base styles) */
@layer base {
#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: 400;
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.5em;
padding-bottom: .02em;
margin-right: 0.6em;
}
.logo-hidden {
.logo {
display: none;
}
}
:host .has-options .logo {
border-left: 1px solid rgba(40,40,40,.4);
margin-left: 0.3em;
margin-right: 0.6em;
}
.logo > span {
white-space: nowrap;
}
/** COMPACT */
/** Hide the menu button normally **/
.compact-menu-button { display: none; }
/** Hide the compact only options when not in compact mode */
.options.compact-only { 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;
max-width: 90vw;
left: 50%;
transform: translateX(-50%);
padding: .2rem 1em;
}
.compact.logo-hidden .foldout {
/** for when there's no logo we want to center the foldout **/
min-width: 24ch;
}
.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.2em;
padding: .6em .5em;
width: 100%;
}
.compact.has-options .logo {
border: none;
padding-left: 0;
margin-bottom: .02em;
}
.compact .options.compact-only {
display: initial;
& > * {
min-height: 1em;
padding: .4em .4em;
}
}
.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;
min-width: 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;
}
}
</style>
<div id="root" class="logo-hidden floating-panel-style bottom">
<div class="wrapper">
<div class="options compact-only" part="options">
</div>
<div class="foldout">
<div class="options main-container" 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" style="display:none;">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) as DocumentFragment;
shadow?.appendChild(content);
this.root = shadow.querySelector("#root") as HTMLDivElement;
this.wrapper = this.root?.querySelector(".wrapper") as HTMLDivElement;
this.options = this.root?.querySelector(".options.main-container") as HTMLDivElement;
this.optionsCompactMode = this.root?.querySelector(".options.compact-only") as HTMLDivElement;
this.logoContainer = this.root?.querySelector(".logo") as HTMLDivElement;
this.compactMenuButton = this.root?.querySelector(".compact-menu-button") as HTMLButtonElement;
this.compactMenuButton.append(getIconElement("more_vert"));
this.foldout = this.root?.querySelector(".foldout") as HTMLDivElement;
this.root?.appendChild(this.wrapper);
this.wrapper.classList.add("wrapper");
const logo = NeedleLogoElement.create();
logo.setType("compact");
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 element is initialized
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();
}
}
ensureInitialized() {
if (!this._didInitialize) {
this._didInitialize = true;
this.initializeDom();
}
}
private _sizeChangeInterval;
connectedCallback() {
this.ensureInitialized();
window.addEventListener("resize", this.handleSizeChange);
this.handleMenuVisible();
this._sizeChangeInterval = setInterval(() => this.handleSizeChange(undefined, false), 5000);
// the dom element is set after initialization 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: Event) => {
// 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 as PointerEvent;
if (!(pointerEvent.clientX > rect.left
&& pointerEvent.clientX < rect.right
&& pointerEvent.clientY > rect.top
&& pointerEvent.clientY < rect.bottom)) {
this.root.classList.toggle("open", false);
}
}
}
/** @private user preference for logo visibility */
private _userRequestedLogoVisible?: boolean = undefined;
showNeedleLogo(visible: boolean) {
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");
}
private ___onSetLogoVisible(visible: boolean) {
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: "top" | "bottom") {
// 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);
}
/** @private user preference for menu visibility */
private _userRequestedMenuVisible?: boolean = undefined;
setVisible(visible: boolean) {
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;
/** @private root container element inside shadow DOM */
private root!: HTMLDivElement;
/** @private wraps the whole content (internal layout) */
private wrapper!: HTMLDivElement;
/** @private contains the buttons and dynamic elements */
private options!: HTMLDivElement;
/** @private contains options visible when in compact mode */
private optionsCompactMode!: HTMLDivElement;
/** @private contains the needle-logo html element */
private logoContainer!: HTMLDivElement;
/** @private compact menu button element */
private compactMenuButton!: HTMLButtonElement;
/** @private foldout container used in compact mode */
private foldout!: HTMLDivElement;
private readonly trackedElements: WeakSet<Node> = new WeakSet();
private trackElement(el: Node) {
if (this.trackedElements.has(el)) return;
this.trackedElements.add(el);
el.addEventListener("click", (evt) => {
Telemetry.sendEvent(this._context, "needle-menu", {
action: "button_clicked",
element: evt.target instanceof Node ? evt.target.nodeName : el.nodeName,
label: el.textContent,
title: (el instanceof HTMLElement) ? el.title : undefined,
pointerid: (evt instanceof PointerEvent) ? evt.pointerId : undefined,
});
});
// el.addEventListener("pointerenter", (evt) => {
// Telemetry.sendEvent(this._context, "needle-menu", {
// action: "button_hovered",
// element: evt.target instanceof Node ? evt.target.nodeName : el.nodeName,
// label: el.textContent,
// title: (el instanceof HTMLElement) ? el.title : undefined,
// pointerid: (evt instanceof PointerEvent) ? evt.pointerId : undefined,
// });
// });
}
append(...nodes: (string | Node)[]): void {
for (const node of nodes) {
if (typeof node === "string") {
const element = document.createTextNode(node);
this.trackElement(element);
this.options.appendChild(element);
} else {
this.trackElement(node);
this.options.appendChild(node);
}
}
}
/**
* Appends a button or HTML element to the needle-menu options.
* @param node a Node or ButtonInfo to create a button from
* @returns the appended Node
*
* @example Append a button
* ```javascript
* const button = document.createElement("button");
* button.textContent = "Click Me";
* needleMenu.appendChild(button);
* ```
* @example Append a button using ButtonInfo
* ```javascript
* needleMenu.appendChild({
* label: "Click Me",
* onClick: () => { alert("Button clicked!"); },
* icon: "info",
* title: "This is a button",
* });
* ```
*/
appendChild<T extends Node>(node: T | ButtonInfo): T {
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 as unknown as T;
}
this.trackElement(node);
const res = this.options.appendChild(node);
return res;
}
prepend(...nodes: (string | Node)[]): void {
for (const node of nodes) {
if (typeof node === "string") {
const element = document.createTextNode(node);
this.trackElement(element);
this.options.prepend(element);
} else {
this.trackElement(node);
this.options.prepend(node);
}
}
}
private _isHandlingChange = false;
/** During modification of options container (e.g. when moving items into the extra buttons container) the mutation observer should not trigger an update event immediately. This is a workaround for the total size required for all elements not being calculated reliably. */
private _pauseMutationObserverOptionsContainer = false;
/** Called when any change in the web component is detected (including in children and child attributes) */
private onChangeDetected(_mut: MutationRecord[]) {
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) {
if (!this._pauseMutationObserverOptionsContainer)
this.onOptionsChildrenChanged(mut)
}
}
}
finally {
this._isHandlingChange = false;
}
}
private onOptionsChildrenChanged(_mut: MutationRecord) {
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);
}
}
}
}
}
private _didSort: Map<HTMLElement, number> = 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
*/
private 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] as HTMLElement
// is slot?
if (child.tagName === "SLOT") {
const slotElement = child as HTMLSlotElement;
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;
}
private _lastAvailableWidthChange = 0;
private _timeoutHandleSize: number = 0;
private _timeoutHandleCompactItems: number = 0;
private handleSizeChange = (_evt?: Event, forceOrEvent?: boolean) => {
if (!this._domElement) return;
// if (this._isApplyingSizeUpdate) return;
const width = this._domElement.clientWidth;
if (width < 100) {
clearTimeout(this._timeoutHandleSize!);
this.root.classList.add("compact");
this.foldout.classList.add("floating-panel-style");
return;
}
const padding = 10 * 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._timeoutHandleSize!);
this._timeoutHandleSize = setTimeout(() => {
// console.warn("APPLY", this.root.classList.contains("compact") ? "COMPACT" : "FULL", "MODE (available width: " + availableWidth.toFixed(0) + "px)", getCurrentWidth());
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");
}
}
this._pauseMutationObserverOptionsContainer = true;
this.updateCompactFoldoutItem();
window.requestAnimationFrame(() => this._pauseMutationObserverOptionsContainer = false);
}, 150) as unknown as number;
const getCurrentWidth = () => {
let totalWidthRequired = 0;
totalWidthRequired += this.options.getBoundingClientRect().width;
totalWidthRequired += this.optionsCompactMode.getBoundingClientRect().width;
totalWidthRequired += 10 * this.options.childElementCount; // padding
totalWidthRequired += this.logoContainer.style.display != "none" ? this.logoContainer.getBoundingClientRect().width : 0;;
return totalWidthRequired;
}
let lastSpaceLeft = -1;
const getSpaceLeft = () => {
const spaceLeft = availableWidth - getCurrentWidth();
if (debug && spaceLeft !== lastSpaceLeft) {
lastSpaceLeft = spaceLeft;
showBalloonMessage(`Menu space left: ${spaceLeft.toFixed(0)}px`);
}
return spaceLeft;
}
}
private updateCompactFoldoutItem() {
if (this.root.classList.contains("compact")) {
// Find items in the folding list with the highest priority
// The one with the highest priority will be added to the visible container
let priorityItem: HTMLElement | null = null;
let priorityValue: number = -10000000;
const testItem = (element: ChildNode | null) => {
if (element instanceof HTMLElement) {
const priority = NeedleMenu.getElementPriority(element);
if (priority !== undefined && priority >= priorityValue) {
// check if the element is hidden
// @TODO: use computed styles
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
return;
}
priorityItem = element;
priorityValue = priority;
}
}
}
for (let i = 0; i < this.options.children.length; i++) {
testItem(this.options.children.item(i));
}
for (let i = 0; i < this.optionsCompactMode.children.length; i++) {
testItem(this.optionsCompactMode.children.item(i));
}
if (priorityItem && !this.optionsCompactMode.contains(priorityItem)) {
this.optionsCompactMode.childNodes.forEach(element => {
this.options.appendChild(element);
});
const item = priorityItem;
this.optionsCompactMode.appendChild(item);
// console.warn("In compact mode, moved item with priority " + priorityValue + " to compact foldout:", item);
}
else if (!priorityItem) {
// console.warn("In compact mode but no item has priority, showing all items in foldout");
this.optionsCompactMode.childNodes.forEach(element => {
this.options.appendChild(element);
});
}
}
else {
// console.warn("Not in compact mode but trying to update compact foldout item");
this.optionsCompactMode.childNodes.forEach(element => {
this.options.appendChild(element);
});
}
}
// private _foldoutItemVisibleInterval = 0;
private ___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");
anotherB