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.

863 lines • 32.1 kB
// use for typesafe interface method calls import { Quaternion, Vector2, Vector3, Vector4 } from "three"; import { ContextRegistry } from "./engine_context_registry.js"; // https://schneidenbach.gitbooks.io/typescript-cookbook/content/nameof-operator.html /** @internal */ export const nameofFactory = () => (name) => name; /** @internal */ export function nameof(name) { return nameofFactory()(name); } /** @internal */ export function isDebugMode() { return getParam("debug") ? true : false; } /** * The circular buffer class can be used to cache objects that don't need to be created every frame. * This structure is used for e.g. Vector3 or Quaternion objects in the engine when calling `getTempVector3` or `getTempQuaternion`. * * @example Create a circular buffer that caches Vector3 objects. Max size is 10. * ```typescript * const buffer = new CircularBuffer(() => new Vector3(), 10); * const vec = buffer.get(); * ``` * * @example Create a circular buffer that caches Quaternion objects. Max size is 1000. * ```typescript * const buffer = new CircularBuffer(() => new Quaternion(), 1000); * const quat = buffer.get(); * ``` */ export class CircularBuffer { _factory; _cache = []; _maxSize; _index = 0; constructor(factory, maxSize) { this._factory = factory; this._maxSize = maxSize; } get() { const i = this._index % this._maxSize; this._index++; if (this._cache.length <= i) { this._cache[i] = this._factory(); } return this._cache[i]; } } let showHelp = false; const requestedParams = new Array(); if (typeof window !== "undefined") { setTimeout(() => { // const debugHelp = getParam("debughelp"); if (showHelp) { const params = {}; const url = new URL(window.location.href); const exampleUrl = new URL(url); exampleUrl.searchParams.append("console", ""); const exampleUrlStr = exampleUrl.toString().replace(/=$|=(?=&)/g, ''); // Filter the params we're interested in for (const param of requestedParams) { const url2 = new URL(url); url2.searchParams.append(param, ""); // Save url with clean parameters (remove trailing = and empty parameters) params[param] = url2.toString().replace(/=$|=(?=&)/g, ''); } console.log("🌵 ?help: Debug Options for Needle Engine.\n" + "Append any of these parameters to the URL to enable specific debug options.\n" + `Example: ${exampleUrlStr} will show an onscreen console window.`); const postfix = showHelp === true ? "" : ` (containing "${showHelp}")`; console.group("Available URL parameters:" + postfix); for (const key of Object.keys(params).sort()) { // If ?help= is a string we only want to show the parameters that contain the string if (typeof showHelp === "string") { if (!key.toLowerCase().includes(showHelp.toLowerCase())) continue; } console.groupCollapsed(key); // Needs to be a separate log, otherwise Safari doesn't turn the next line into a URL: console.log("Reload with this flag enabled:"); console.log(params[key]); console.groupEnd(); } console.groupEnd(); } }, 100); } export function getUrlParams() { // "window" may not exist in node.js return new URLSearchParams(globalThis.location?.search); } /** * Checks if a URL parameter exists and returns its value. * Useful for debugging, feature flags, and configuration. * * @param paramName The URL parameter name to check * @returns * - `true` if the parameter exists without a value (e.g. `?debug`) * - `false` if the parameter doesn't exist or is set to `0` * - The numeric value if it's a number (e.g. `?level=5` returns `5`) * - The string value otherwise (e.g. `?name=test` returns `"test"`) * * @example Check debug mode * ```ts * if (getParam("debug")) { * console.log("Debug mode enabled"); * } * ``` * @example Get a numeric value * ```ts * const level = getParam("level"); // Returns number if ?level=5 * ``` */ export function getParam(paramName) { if (showHelp && !requestedParams.includes(paramName)) requestedParams.push(paramName); const urlParams = getUrlParams(); if (urlParams.has(paramName)) { const val = urlParams.get(paramName); if (val) { const num = Number(val); if (!isNaN(num)) return num; return val; } else return true; } return false; } showHelp = getParam("help"); export function setParam(paramName, paramValue) { const urlParams = getUrlParams(); if (urlParams.has(paramName)) { urlParams.set(paramName, paramValue); } else urlParams.append(paramName, paramValue); document.location.search = urlParams.toString(); } /** Sets an URL parameter without reloading the website */ export function setParamWithoutReload(paramName, paramValue, appendHistory = true) { const urlParams = getUrlParams(); if (urlParams.has(paramName)) { if (paramValue === null) urlParams.delete(paramName); else urlParams.set(paramName, paramValue); } else if (paramValue !== null) urlParams.append(paramName, paramValue); if (appendHistory) pushState(paramName, urlParams); else setState(paramName, urlParams); } /** Sets or adds an URL query parameter */ export function setOrAddParamsToUrl(url, paramName, paramValue) { if (url.has(paramName)) { url.set(paramName, paramValue.toString()); } else url.append(paramName, paramValue.toString()); } /** Adds an entry to the browser history. Internally uses `window.history.pushState` */ export function pushState(title, urlParams, state) { window.history.pushState(state, title, "?" + urlParams.toString()); } /** Replaces the current entry in the browser history. Internally uses `window.history.replaceState` */ export function setState(title, urlParams, state) { window.history.replaceState(state, title, "?" + urlParams.toString()); } // for room id /** Generates a random id string of the given length */ export function makeId(length) { var result = ''; var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; var charactersLength = characters.length; for (var i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; } /** Generates a random number * @deprecated use Mathf.random(min, max) */ export function randomNumber(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } const adjectives = ["smol", "tiny", "giant", "interesting", "smart", "bright", "dull", "extreme", "beautiful", "pretty", "dark", "epic", "salty", "silly", "funny", "lame", "lazy", "loud", "lucky", "mad", "mean", "mighty", "mysterious", "nasty", "odd", "old", "powerful", "quiet", "rapid", "scary", "shiny", "shy", "silly", "smooth", "sour", "spicy", "stupid", "sweet", "tasty", "terrible", "ugly", "unusual", "vast", "wet", "wild", "witty", "wrong", "zany", "zealous", "zippy", "zombie", "zorro"]; const nouns = ["cat", "dog", "mouse", "pig", "cow", "horse", "sheep", "chicken", "duck", "goat", "panda", "tiger", "lion", "elephant", "monkey", "bird", "fish", "snake", "frog", "turtle", "hamster", "penguin", "kangaroo", "whale", "dolphin", "crocodile", "snail", "ant", "bee", "beetle", "butterfly", "dragon", "eagle", "fish", "giraffe", "lizard", "panda", "penguin", "rabbit", "snake", "spider", "tiger", "zebra"]; /** Generates a random id string from a list of adjectives and nouns */ export function makeIdFromRandomWords() { const randomAdjective = adjectives[Math.floor(Math.random() * adjectives.length)]; const randomNoun = nouns[Math.floor(Math.random() * nouns.length)]; return randomAdjective + "_" + randomNoun; } // for url parameters export function sanitizeString(str) { str = str.replace(/[^a-z0-9áéíóúñü \.,_-]/gim, ""); return str.trim(); } // TODO: taken from scene utils /** * @param globalObjectIdentifier The guid of the object to find * @param obj The object to search in * @param recursive If true the search will be recursive * @param searchComponents If true the search will also search components * @returns the first object that has the globalObjectIdentifier as a guid */ export function tryFindObject(globalObjectIdentifier, obj, recursive = true, searchComponents = false) { if (obj === undefined || obj === null) return null; if (obj.userData && obj.userData.guid === globalObjectIdentifier) return obj; else if (obj.guid == globalObjectIdentifier) return obj; if (searchComponents) { if (obj.userData?.components) { for (const comp of obj.userData.components) { if (comp.guid === globalObjectIdentifier) return comp; } } } if (recursive) { if (obj.scenes) { for (const i in obj.scenes) { const scene = obj.scenes[i]; const found = tryFindObject(globalObjectIdentifier, scene, recursive, searchComponents); if (found) return found; } } if (obj.children) { for (const i in obj.children) { const child = obj.children[i]; const found = tryFindObject(globalObjectIdentifier, child, recursive, searchComponents); if (found) return found; } } } } /** Deep clones an object * @param obj The object to clone * @param predicate A function that can be used to skip certain properties from being cloned * @returns The cloned object * @example * const clone = deepClone(obj, (owner, propertyName, current) => { * if (propertyName === "dontCloneMe") return false; * return true; * }); * */ export function deepClone(obj, predicate) { if (obj !== null && obj !== undefined && typeof obj === "object") { let clone; if (Array.isArray(obj)) clone = []; else { clone = Object.create(obj); Object.assign(clone, obj); } for (const key of Object.keys(obj)) { const val = obj[key]; if (predicate && !predicate(obj, key, val)) { // console.log("SKIP", val); clone[key] = val; } else if (val?.clone !== undefined && typeof val.clone === "function") clone[key] = val.clone(); else clone[key] = deepClone(val, predicate); } return clone; } return obj; } /** Wait for a specific amount of milliseconds to pass * @returns a promise that resolves after a certain amount of milliseconds * @example * ```typescript * await delay(1000); * ``` */ export function delay(milliseconds) { return new Promise((resolve, _reject) => { setTimeout(resolve, milliseconds); }); } /** Will wait for a specific amount of frames to pass * @param frameCount The amount of frames to wait for * @param context The context to use, if not provided the current context will be used * @returns a promise that resolves after a certain amount of frames * @example * ```typescript * await delayForFrames(10); * ``` */ export function delayForFrames(frameCount, context) { if (frameCount <= 0) return Promise.resolve(); if (!context) context = ContextRegistry.Current; if (!context) return Promise.reject("No context"); const endFrame = context.time.frameCount + frameCount; return new Promise((resolve, reject) => { if (!context) return reject("No context"); const cb = () => { if (context.time.frameCount >= endFrame) { context.pre_update_callbacks.splice(context.pre_update_callbacks.indexOf(cb), 1); resolve(); } }; context.pre_update_callbacks.push(cb); }); } // 1) if a timeline is exported via menu item the audio clip path is relative to the glb (same folder) // we need to detect that here and build the new audio source path relative to the new glb location // the same is/might be true for any file that is/will be exported via menu item // 2) if the needle.config assetDirectory is modified (from e.g. /assets to /needle/assets) when building a distributable our vite transform and copy plugin will move the files to dist/assets hence we cannot use project-relative paths (because the path changes). What we do instead if make all paths serialized in a glb relative to the glb. The rel: prefix is used to detect urls that need to be resolved. const debugGetPath = getParam("debugresolveurl"); export const relativePathPrefix = "rel:"; /** @deprecated use resolveUrl instead */ export function getPath(source, uri) { return resolveUrl(source, uri); } /** * Use to resolve a url serialized in a glTF file * @param source The uri of the loading file * @param uri The uri of the file to resolve, can be absolute or relative * @returns The resolved uri */ export function resolveUrl(source, uri) { if (uri === undefined) { if (debugGetPath) console.warn("getPath: uri is undefined, returning uri", uri); return uri; } if (uri.startsWith("./")) { return uri; } if (uri.startsWith("http")) { if (debugGetPath) console.warn("getPath: uri is absolute, returning uri", uri); return uri; } if (source === undefined) { if (debugGetPath) console.warn("getPath: source is undefined, returning uri", uri); return uri; } if (uri.startsWith(relativePathPrefix)) { uri = uri.substring(4); } const pathIndex = source.lastIndexOf("/"); if (pathIndex >= 0) { // Take the source uri as the base path const basePath = source.substring(0, pathIndex + 1); // make sure we don't have double slashes while (basePath.endsWith("/") && uri.startsWith("/")) uri = uri.substring(1); // Append the relative uri const newUri = basePath + uri; // newUri = new URL(newUri, globalThis.location.href).href; if (debugGetPath) console.log("source:", source, "changed uri \nfrom", uri, "\nto ", newUri, "\nbasePath: " + basePath); return newUri; } return uri; } export function toSourceId(src) { if (!src) return undefined; src = src.trim(); src = src.split("?")[0]?.split("#")[0]; return src; } // TODO: make it possible to add multiple watches to the same object property class WatchImpl { subscribeWrite(callback) { this.writeCallbacks.push(callback); } unsubscribeWrite(callback) { const i = this.writeCallbacks.indexOf(callback); if (i === -1) return; this.writeCallbacks.splice(i, 1); } writeCallbacks = []; constructor(object, prop) { this._object = object; this._prop = prop; this._wrapperProp = Symbol("$" + prop); this.apply(); } _applied = false; _object; _prop; _wrapperProp; apply() { if (this._applied) return; if (!this._object) return; const object = this._object; const prop = this._prop; if (object[prop] === undefined) return; this._applied = true; if (object[this._wrapperProp] !== undefined) { console.warn("Watcher is being applied to an object that already has a wrapper property. This is not (yet) supported"); } // create a wrapper property const current = object[prop]; object[this._wrapperProp] = current; // create wrapper methods const getter = () => { return object[this._wrapperProp]; }; const setter = (value) => { object[this._wrapperProp] = value; for (const write of this.writeCallbacks) { write(value, this._prop); } }; // add the wrapper to the object Object.defineProperty(object, prop, { get: getter, set: setter }); } revoke() { if (!this._applied) return; if (!this._object) return; this._applied = false; const object = this._object; const prop = this._prop; Reflect.deleteProperty(object, prop); const current = object[this._wrapperProp]; object[prop] = current; Reflect.deleteProperty(object, this._wrapperProp); } dispose() { this.revoke(); this.writeCallbacks.length = 0; this._object = null; } } export class Watch { _watches = []; constructor(object, str) { if (Array.isArray(str)) { for (const s of str) { this._watches.push(new Watch(object, s)); } } else { this._watches.push(new WatchImpl(object, str)); } } subscribeWrite(callback) { for (const w of this._watches) { w.subscribeWrite(callback); } } unsubscribeWrite(callback) { for (const w of this._watches) { w.unsubscribeWrite(callback); } } apply() { for (const w of this._watches) { w.apply(); } } revoke() { for (const w of this._watches) { w.revoke(); } } dispose() { for (const w of this._watches) { w.dispose(); } this._watches.length = 0; } } const watchesKey = Symbol("needle:watches"); /** Subscribe to an object being written to * Currently supporting Vector3 */ export function watchWrite(vec, cb) { if (!vec[watchesKey]) { if (vec instanceof Vector2) { vec[watchesKey] = new Watch(vec, ["x", "y"]); } else if (vec instanceof Vector3) { vec[watchesKey] = new Watch(vec, ["x", "y", "z"]); } else if (vec instanceof Vector4 || vec instanceof Quaternion) { vec[watchesKey] = new Watch(vec, ["x", "y", "z", "w"]); } else { return false; } } vec[watchesKey].subscribeWrite(cb); return true; } export function unwatchWrite(vec, cb) { if (!vec) return; const watch = vec[watchesKey]; if (!watch) return; watch.unsubscribeWrite(cb); } ; /** * Utility functions to detect certain device types (mobile, desktop), browsers, or capabilities. * @category Utilities */ export var DeviceUtilities; (function (DeviceUtilities) { let _isDesktop; /** @returns `true` for MacOS or Windows devices. `false` for Hololens and other headsets. */ function isDesktop() { if (_isDesktop !== undefined) return _isDesktop; const ua = window.navigator.userAgent; const standalone = /Windows|MacOS|Mac OS/.test(ua); const isHololens = /Windows NT/.test(ua) && /Edg/.test(ua) && !/Win64/.test(ua); return _isDesktop = standalone && !isHololens && !isiOS(); } DeviceUtilities.isDesktop = isDesktop; let _ismobile; /** @returns `true` if it's a phone or tablet */ function isMobileDevice() { if (_ismobile !== undefined) return _ismobile; // eslint-disable-next-line @typescript-eslint/no-deprecated if ((typeof window.orientation !== "undefined") || (navigator.userAgent.indexOf('IEMobile') !== -1)) { return _ismobile = true; } return _ismobile = /iPhone|iPad|iPod|Android|IEMobile/i.test(navigator.userAgent); } DeviceUtilities.isMobileDevice = isMobileDevice; /** @deprecated use {@link isiPad} instead */ function isIPad() { return isiPad(); } DeviceUtilities.isIPad = isIPad; let __isiPad; /** @returns `true` if we're currently on an iPad */ function isiPad() { if (__isiPad !== undefined) return __isiPad; const userAgent = navigator.userAgent.toLowerCase(); return __isiPad = /iPad/.test(navigator.userAgent) || userAgent.includes("macintosh") && "ontouchend" in document; } DeviceUtilities.isiPad = isiPad; let __isAndroidDevice; /** @returns `true` if we're currently on an Android device */ function isAndroidDevice() { if (__isAndroidDevice !== undefined) return __isAndroidDevice; return __isAndroidDevice = /Android/.test(navigator.userAgent); } DeviceUtilities.isAndroidDevice = isAndroidDevice; let __isMozillaXR; /** @returns `true` if we're currently using the Mozilla XR Browser (only available for iOS) */ function isMozillaXR() { if (__isMozillaXR !== undefined) return __isMozillaXR; return __isMozillaXR = /WebXRViewer\//i.test(navigator.userAgent); } DeviceUtilities.isMozillaXR = isMozillaXR; let __isNeedleAppClip; /** @returns `true` if we're currently in the Needle App Clip */ function isNeedleAppClip() { if (__isNeedleAppClip !== undefined) return __isNeedleAppClip; return __isNeedleAppClip = /NeedleAppClip\//i.test(navigator.userAgent); } DeviceUtilities.isNeedleAppClip = isNeedleAppClip; let __isMacOS; // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgentData /** @returns `true` for MacOS devices */ function isMacOS() { if (__isMacOS !== undefined) return __isMacOS; if (isiOS() || isiPad()) return __isMacOS = false; const userAgent = navigator.userAgent.toLowerCase(); if (navigator.userAgentData) { // Use modern UA Client Hints API if available return __isMacOS = navigator.userAgentData.platform === 'macOS'; } else { // Fallback to user agent string parsing return __isMacOS = userAgent.includes('mac os x') || userAgent.includes('macintosh'); } } DeviceUtilities.isMacOS = isMacOS; let __isVisionOS; /** @returns `true` for VisionOS devices */ function isVisionOS() { if (__isVisionOS !== undefined) return __isVisionOS; return __isVisionOS = (isiPad() && "xr" in navigator && supportsQuickLookAR()); } DeviceUtilities.isVisionOS = isVisionOS; let __isiOS; const iosDevices = ['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod']; /** @returns `true` for mobile Apple devices like iPad, iPhone, iPod, Vision Pro, ... */ function isiOS() { if (__isiOS !== undefined) return __isiOS; // eslint-disable-next-line @typescript-eslint/no-deprecated return __isiOS = iosDevices.includes(navigator.platform) // iPad on iOS 13 detection || (navigator.userAgent.includes("Mac") && "ontouchend" in document); } DeviceUtilities.isiOS = isiOS; let __isSafari; /** @returns `true` if we're currently on safari */ function isSafari() { if (__isSafari !== undefined) return __isSafari; __isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); return __isSafari; } DeviceUtilities.isSafari = isSafari; let __isQuest; /** @returns `true` for Meta Quest devices and browser. */ function isQuest() { if (__isQuest !== undefined) return __isQuest; return __isQuest = navigator.userAgent.includes("OculusBrowser"); } DeviceUtilities.isQuest = isQuest; let __supportsQuickLookAR; /** @returns `true` if the browser has `<a rel="ar">` support, which indicates USDZ QuickLook support. */ function supportsQuickLookAR() { if (__supportsQuickLookAR !== undefined) return __supportsQuickLookAR; const a = document.createElement("a"); __supportsQuickLookAR = a.relList.supports("ar"); return __supportsQuickLookAR; } DeviceUtilities.supportsQuickLookAR = supportsQuickLookAR; /** @returns `true` if the user allowed to use the microphone */ async function microphonePermissionsGranted() { try { //@ts-ignore const res = await navigator.permissions.query({ name: 'microphone' }); if (res.state === "denied") { return false; } return true; } catch (err) { console.error("Error querying `microphone` permissions.", err); return false; } } DeviceUtilities.microphonePermissionsGranted = microphonePermissionsGranted; let __iOSVersion; function getiOSVersion() { if (__iOSVersion !== undefined) return __iOSVersion; const match = navigator.userAgent.match(/iPhone OS (\d+_\d+)/); if (match) __iOSVersion = match[1].replace("_", "."); if (!__iOSVersion) { // Look for "(Macintosh;" or "(iPhone;" or "(iPad;" and then check Version/18.0 const match2 = navigator.userAgent.match(/(?:\(Macintosh;|iPhone;|iPad;).*Version\/(\d+\.\d+)/); if (match2) __iOSVersion = match2[1]; } // if we dont have any match we set it to null to avoid running the check again if (!__iOSVersion) { __iOSVersion = null; } return __iOSVersion; } DeviceUtilities.getiOSVersion = getiOSVersion; let __chromeVersion; function getChromeVersion() { if (__chromeVersion !== undefined) return __chromeVersion; const match = navigator.userAgent.match(/(?:CriOS|Chrome)\/(\d+\.\d+\.\d+\.\d+)/); if (match) { const result = match[1].replace("_", "."); __chromeVersion = result; } else __chromeVersion = null; return __chromeVersion; } DeviceUtilities.getChromeVersion = getChromeVersion; let __safariVersion; function getSafariVersion() { if (__safariVersion !== undefined) return __safariVersion; const match = navigator.userAgent.match(/Version\/(\d+\.\d+)/); if (match && isSafari()) { __safariVersion = match[1]; } else __safariVersion = null; return __safariVersion; } DeviceUtilities.getSafariVersion = getSafariVersion; })(DeviceUtilities || (DeviceUtilities = {})); /** * @deprecated use {@link DeviceUtilities.isDesktop} instead */ export function isDesktop() { return DeviceUtilities.isDesktop(); } /** * @deprecated use {@link DeviceUtilities.isMobileDevice} instead */ export function isMobileDevice() { return DeviceUtilities.isMobileDevice(); } /** @deprecated use {@link DeviceUtilities.isiPad} instead */ export function isIPad() { return DeviceUtilities.isiPad(); } /** @deprecated use {@link DeviceUtilities.isiPad} instead */ export function isiPad() { return DeviceUtilities.isiPad(); } /** @deprecated use {@link DeviceUtilities.isAndroidDevice} instead */ export function isAndroidDevice() { return DeviceUtilities.isAndroidDevice(); } /** @deprecated use {@link DeviceUtilities.isMozillaXR} instead */ export function isMozillaXR() { return DeviceUtilities.isMozillaXR(); } // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgentData /** @deprecated use {@link DeviceUtilities.isMacOS} instead */ export function isMacOS() { return DeviceUtilities.isMacOS(); } /** @deprecated use {@link DeviceUtilities.isiOS} instead */ export function isiOS() { return DeviceUtilities.isiOS(); } /** @deprecated use {@link DeviceUtilities.isSafari} instead */ export function isSafari() { return DeviceUtilities.isSafari(); } /** @deprecated use {@link DeviceUtilities.isQuest} instead */ export function isQuest() { return DeviceUtilities.isQuest(); } /** @deprecated use {@link DeviceUtilities.microphonePermissionsGranted} instead */ export async function microphonePermissionsGranted() { return DeviceUtilities.microphonePermissionsGranted(); } const mutationObserverMap = new WeakMap(); /** * Register a callback when an {@link HTMLElement} attribute changes. * This is used, for example, by the Skybox component to watch for changes to the environment-* and skybox-* attributes. * @returns A function that can be used to unregister the callback */ export function addAttributeChangeCallback(domElement, name, callback) { if (!mutationObserverMap.get(domElement)) { const observer = new MutationObserver((mutations) => { handleMutations(domElement, mutations); }); mutationObserverMap.set(domElement, { observer, attributeChangedListeners: new Map(), }); observer.observe(domElement, { attributes: true }); } const listeners = mutationObserverMap.get(domElement).attributeChangedListeners; if (!listeners.has(name)) { listeners.set(name, []); } listeners.get(name).push(callback); return () => { removeAttributeChangeCallback(domElement, name, callback); }; } ; /** * Unregister a callback previously registered with {@link addAttributeChangeCallback}. */ export function removeAttributeChangeCallback(domElement, name, callback) { if (!mutationObserverMap.get(domElement)) return; const listeners = mutationObserverMap.get(domElement).attributeChangedListeners; if (!listeners.has(name)) return; const arr = listeners.get(name); const index = arr.indexOf(callback); if (index === -1) return; arr.splice(index, 1); if (arr.length <= 0) { listeners.delete(name); const entry = mutationObserverMap.get(domElement); entry?.observer.disconnect(); mutationObserverMap.delete(domElement); } } function handleMutations(domElement, mutations) { const listeners = mutationObserverMap.get(domElement).attributeChangedListeners; for (const mut of mutations) { if (mut.type === "attributes") { const name = mut.attributeName; const value = domElement.getAttribute(name); if (listeners.has(name)) { for (const listener of listeners.get(name)) { listener(value); } } } } } /** Used by `PromiseAllWithErrors` */ export class PromiseErrorResult { reason; constructor(reason) { this.reason = reason; } } /** Can be used to simplify Promise error handling and if errors are acceptable. * Promise.all will just fail if any of the provided promises fails and not return or cancel pending promises or partial results * Using Promise.allSettled (or this method) instead will return a result for each promise and not automatically fail if any of the promises fails. * Instead it will return a promise containing information if any of the promises failed * and the actual results will be available as `results` array **/ export async function PromiseAllWithErrors(promise) { const results = await Promise.allSettled(promise).catch(err => { return [ new PromiseErrorResult(err.message) ]; }); let anyFailed = false; const res = results.map(x => { if ("value" in x) return x.value; anyFailed = true; return new PromiseErrorResult(x.reason); }); return { anyFailed: anyFailed, results: res, }; } //# sourceMappingURL=engine_utils.js.map