@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.
936 lines (828 loc) • 34 kB
text/typescript
// use for typesafe interface method calls
import { Quaternion, Vector2, Vector3, Vector4 } from "three";
declare type Vector = Vector2 | Vector3 | Vector4 | Quaternion;
import type { Context } from "./engine_context.js";
import { ContextRegistry } from "./engine_context_registry.js";
import { type SourceIdentifier } from "./engine_types.js";
// https://schneidenbach.gitbooks.io/typescript-cookbook/content/nameof-operator.html
/** @internal */
export const nameofFactory = <T>() => (name: keyof T) => name;
/** @internal */
export function nameof<T>(name: keyof T) {
return nameofFactory<T>()(name);
}
type ParseNumber<T> = T extends `${infer U extends number}` ? U : never;
export type EnumToPrimitiveUnion<T> = `${T & string}` | ParseNumber<`${T & number}`>;
/** @internal */
export function isDebugMode(): boolean {
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<T> {
private _factory: () => T;
private _cache: T[] = [];
private _maxSize: number;
private _index: number = 0;
constructor(factory: () => T, maxSize: number) {
this._factory = factory;
this._maxSize = maxSize;
}
get(): T {
const i = this._index % this._maxSize;
this._index++;
if (this._cache.length <= i) {
this._cache[i] = this._factory();
}
return this._cache[i];
}
}
let showHelp: Param<"help"> = false;
const requestedParams: Array<string> = 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);
}
// bit strange that we have to pass T in here as well but otherwise the type parameter is stripped it seems
type Param<T extends string> = string | boolean | number | T;
/**
* 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<T extends string>(paramName: T): Param<T> {
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: string, paramValue: string): void {
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: string, paramValue: string | null, appendHistory = true): void {
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: URLSearchParams, paramName: string, paramValue: string | number): void {
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: string, urlParams: URLSearchParams, state?: any) {
window.history.pushState(state, title, "?" + urlParams.toString());
}
/** Replaces the current entry in the browser history. Internally uses `window.history.replaceState` */
export function setState(title: string, urlParams: URLSearchParams, state?: any) {
window.history.replaceState(state, title, "?" + urlParams.toString());
}
// for room id
/** Generates a random id string of the given length */
export function makeId(length): string {
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: number, max: number) {
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(): string {
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): string {
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: string, obj, recursive: boolean = true, searchComponents: boolean = 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;
}
}
}
}
declare type deepClonePredicate = (owner: any, propertyName: string, current: any) => boolean;
/** 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: any, predicate?: deepClonePredicate): any {
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: number): Promise<void> {
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: number, context?: Context): Promise<void> {
if (frameCount <= 0) return Promise.resolve();
if (!context) context = ContextRegistry.Current as Context;
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: SourceIdentifier | undefined, uri: string): string {
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: SourceIdentifier | undefined, uri: string): string {
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: string | null): SourceIdentifier | undefined {
if (!src) return undefined;
src = src.trim();
src = src.split("?")[0]?.split("#")[0];
return src;
}
// export function getPath(glbLocation: SourceIdentifier | undefined, path: string) {
// if (path && glbLocation && !path.includes("/")) {
// // get directory of glb and prepend it to the audio file path
// const pathIndex = glbLocation.lastIndexOf("/");
// if (pathIndex >= 0) {
// const newPath = glbLocation.substring(0, pathIndex + 1) + path;
// return newPath;
// }
// }
// return path;
// }
export type WriteCallback = (data: any, prop: string) => void;
export interface IWatch {
subscribeWrite(callback: WriteCallback);
unsubscribeWrite(callback: WriteCallback);
apply();
revoke();
dispose();
}
// TODO: make it possible to add multiple watches to the same object property
class WatchImpl implements IWatch {
subscribeWrite(callback: WriteCallback) {
this.writeCallbacks.push(callback);
}
unsubscribeWrite(callback: WriteCallback) {
const i = this.writeCallbacks.indexOf(callback);
if (i === -1) return;
this.writeCallbacks.splice(i, 1);
}
private writeCallbacks: (WriteCallback)[] = [];
constructor(object: object, prop: string) {
this._object = object;
this._prop = prop;
this._wrapperProp = Symbol("$" + prop);
this.apply();
}
private _applied: boolean = false;
private _object: any;
private _prop: string;
private _wrapperProp: symbol;
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 implements IWatch {
private readonly _watches: IWatch[] = [];
constructor(object: object, str: string[] | string) {
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: WriteCallback) {
for (const w of this._watches) {
w.subscribeWrite(callback);
}
}
unsubscribeWrite(callback: WriteCallback) {
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: Vector, cb: Function) {
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: Vector, cb: Function) {
if (!vec) return;
const watch = vec[watchesKey];
if (!watch) return;
watch.unsubscribeWrite(cb);
};
declare global {
interface NavigatorUAData {
platform: string;
}
interface Navigator {
userAgentData?: NavigatorUAData;
}
}
/**
* Utility functions to detect certain device types (mobile, desktop), browsers, or capabilities.
* @category Utilities
*/
export namespace DeviceUtilities {
let _isDesktop: boolean | undefined;
/** @returns `true` for MacOS or Windows devices. `false` for Hololens and other headsets. */
export 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();
}
let _ismobile: boolean | undefined;
/** @returns `true` if it's a phone or tablet */
export 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);
}
/** @deprecated use {@link isiPad} instead */
export function isIPad() {
return isiPad();
}
let __isiPad: boolean | undefined;
/** @returns `true` if we're currently on an iPad */
export function isiPad() {
if (__isiPad !== undefined) return __isiPad;
const userAgent = navigator.userAgent.toLowerCase();
return __isiPad = /iPad/.test(navigator.userAgent) || userAgent.includes("macintosh") && "ontouchend" in document;
}
let __isAndroidDevice: boolean | undefined;
/** @returns `true` if we're currently on an Android device */
export function isAndroidDevice() {
if (__isAndroidDevice !== undefined) return __isAndroidDevice;
return __isAndroidDevice = /Android/.test(navigator.userAgent);
}
let __isMozillaXR: boolean | undefined;
/** @returns `true` if we're currently using the Mozilla XR Browser (only available for iOS) */
export function isMozillaXR() {
if (__isMozillaXR !== undefined) return __isMozillaXR;
return __isMozillaXR = /WebXRViewer\//i.test(navigator.userAgent);
}
let __isNeedleAppClip: boolean | undefined;
/** @returns `true` if we're currently in the Needle App Clip */
export function isNeedleAppClip() {
if (__isNeedleAppClip !== undefined) return __isNeedleAppClip;
return __isNeedleAppClip = /NeedleAppClip\//i.test(navigator.userAgent);
}
let __isMacOS: boolean | undefined;
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgentData
/** @returns `true` for MacOS devices */
export 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');
}
}
let __isVisionOS: boolean | undefined;
/** @returns `true` for VisionOS devices */
export function isVisionOS() {
if (__isVisionOS !== undefined) return __isVisionOS;
return __isVisionOS = (isiPad() && "xr" in navigator && supportsQuickLookAR());
}
let __isiOS: boolean | undefined;
const iosDevices = ['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'];
/** @returns `true` for mobile Apple devices like iPad, iPhone, iPod, Vision Pro, ... */
export 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)
}
let __isSafari: boolean | undefined;
/** @returns `true` if we're currently on safari */
export function isSafari() {
if (__isSafari !== undefined) return __isSafari;
__isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
return __isSafari;
}
let __isQuest: boolean | undefined;
/** @returns `true` for Meta Quest devices and browser. */
export function isQuest() {
if (__isQuest !== undefined) return __isQuest;
return __isQuest = navigator.userAgent.includes("OculusBrowser");
}
let __supportsQuickLookAR: boolean | undefined;
/** @returns `true` if the browser has `<a rel="ar">` support, which indicates USDZ QuickLook support. */
export function supportsQuickLookAR() {
if (__supportsQuickLookAR !== undefined) return __supportsQuickLookAR;
const a = document.createElement("a") as HTMLAnchorElement;
__supportsQuickLookAR = a.relList.supports("ar");
return __supportsQuickLookAR;
}
/** @returns `true` if the user allowed to use the microphone */
export 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;
}
}
let __iOSVersion: string | null | undefined;
export 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;
}
let __chromeVersion: string | null | undefined;
export 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;
}
let __safariVersion: string | null | undefined;
export 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;
}
}
/**
* @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();
}
declare type AttributeChangeCallback = (value: string | null) => void;
declare type HtmlElementExtra = {
observer: MutationObserver,
attributeChangedListeners: Map<string, Array<AttributeChangeCallback>>,
}
const mutationObserverMap = new WeakMap<HTMLElement, HtmlElementExtra>();
/**
* 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: HTMLElement, name: string, callback: AttributeChangeCallback) {
if (!mutationObserverMap.get(domElement)) {
const observer = new MutationObserver((mutations) => {
handleMutations(domElement, mutations);
});
mutationObserverMap.set(domElement, {
observer,
attributeChangedListeners: new Map<string, Array<AttributeChangeCallback>>(),
});
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: HTMLElement, name: string, callback: AttributeChangeCallback) {
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: HTMLElement, mutations: Array<MutationRecord>) {
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 {
readonly reason: string;
constructor(reason: string) {
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<T>(promise: Promise<T>[]): Promise<{
anyFailed: boolean,
results: Array<T | PromiseErrorResult>
}> {
const results = await Promise.allSettled(promise).catch(err => {
return [
new PromiseErrorResult(err.message)
];
})
let anyFailed: boolean = 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,
};
}