@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,116 lines (982 loc) • 43.2 kB
text/typescript
// use for typesafe interface method calls
import { Quaternion, Vector2, Vector3, Vector4 } from "three";
declare type Vector = Vector2 | Vector3 | Vector4 | Quaternion;
import { needleLogoOnlySVG } from "./assets/index.js";
import type { Context } from "./engine_context.js";
import { ContextRegistry } from "./engine_context_registry.js";
import { type SourceIdentifier } from "./engine_types.js";
import type { NeedleEngineWebComponent } from "./webcomponents/needle-engine.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.
* Returns true if it exists but has no value (e.g. ?help)
* Returns false if it does not exist
* Returns false if it's set to 0 e.g. ?debug=0
* Returns the value if it exists e.g. ?message=hello
*/
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 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.
*/
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 deprecation/deprecation
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;
return __isiPad = /iPad/.test(navigator.userAgent);
}
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 __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 (navigator.userAgentData) {
// Use modern UA Client Hints API if available
return __isMacOS = navigator.userAgentData.platform === 'macOS';
} else {
// Fallback to user agent string parsing
const userAgent = navigator.userAgent.toLowerCase();
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 = (isMacOS() && "xr" in navigator);
}
let __isiOS: boolean | undefined;
const iosDevices = ['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'];
/** @returns `true` for iOS devices like iPad, iPhone, iPod... */
export function isiOS() {
if (__isiOS !== undefined) return __isiOS;
// eslint-disable-next-line deprecation/deprecation
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;
}
}
/**
* @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.
*/
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);
};
/**
* 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,
};
}
/** Generates a QR code HTML image using https://github.com/davidshimjs/qrcodejs
* @param args.text The text to encode
* @param args.width The width of the QR code
* @param args.height The height of the QR code
* @param args.colorDark The color of the dark squares
* @param args.colorLight The color of the light squares
* @param args.correctLevel The error correction level to use
* @param args.showLogo If true, the same logo as for the Needle loading screen will be drawn in the center of the QR code
* @param args.showUrl If true, the URL will be shown below the QR code
* @param args.domElement The dom element to append the QR code to. If not provided a new div will be created and returned
* @returns The dom element containing the QR code
*/
export async function generateQRCode(args: { domElement?: HTMLElement, text: string, width?: number, height?: number, colorDark?: string, colorLight?: string, correctLevel?: any, showLogo?: boolean, showUrl?: boolean }): Promise<HTMLElement> {
// Ensure that the QRCode library is loaded
if (!globalThis["QRCode"]) {
const url = "https://cdn.jsdelivr.net/gh/davidshimjs/qrcodejs@gh-pages/qrcode.min.js";
let script = document.head.querySelector(`script[src="${url}"]`) as HTMLScriptElement;
if (!script) {
script = document.createElement("script");
script.src = url;
document.head.appendChild(script);
}
await new Promise((resolve, _reject) => {
script.addEventListener("load", () => {
resolve(true);
});
});
}
const QRCODE = globalThis["QRCode"];
const target = args.domElement ?? document.createElement("div");
const qrCode = new QRCODE(target, {
width: args.width ?? 256,
height: args.height ?? 256,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: args.showLogo ? QRCODE.CorrectionLevel.H : QRCODE.CorrectLevel.M,
...args,
});
// Number of rows/columns of the generated QR code
const moduleCount = qrCode?._oQRCode.moduleCount || 0;
const canvas = qrCode?._oDrawing?._elCanvas as HTMLCanvasElement;
let sizePercentage = 0.25;
if (moduleCount < 40)
sizePercentage = Math.floor(moduleCount / 4) / moduleCount;
else
sizePercentage = Math.floor(moduleCount / 6) / moduleCount;
const paddingPercentage = Math.floor(moduleCount / 20) / moduleCount;
try {
const img = await addOverlays(canvas, { showLogo: args.showLogo, logoSize: sizePercentage, logoPadding: paddingPercentage }).catch(_e => { /** ignore */ });
if (img) {
target.innerHTML = "";
target.append(img);
}
}
catch { } // Ignore
if (args.showUrl !== false && args.text) {
// Add link label below the QR code
// Clean up the text. If it's a URL: remove the protocol, www. part, trailing slashes or trailing question marks
const existingLabel = target.querySelector(".qr-code-link-label");
let displayText = args.text.replace(/^(https?:\/\/)?(www\.)?/, "").replace(/\/+$/, "").replace(/\?+$/, "");
displayText = "Scan to visit " + displayText;
if (existingLabel) {
existingLabel.textContent = displayText;
} else {
// Create a new label
const linkLabel = document.createElement("div");
linkLabel.classList.add("qr-code-link-label");
args.text = displayText;
linkLabel.textContent = args.text;
linkLabel.addEventListener("click", (ev) => {
// Prevent the QR panel from closing
ev.stopImmediatePropagation();
});
linkLabel.style.textAlign = "center";
linkLabel.style.fontSize = "0.8em";
linkLabel.style.marginTop = "0.1em";
linkLabel.style.color = "#000000";
linkLabel.style.fontFamily = "'Roboto Flex', sans-serif";
linkLabel.style.opacity = "0.5";
linkLabel.style.wordBreak = "break-all";
linkLabel.style.wordWrap = "break-word";
linkLabel.style.marginBottom = "0.3em";
// Ensure max. width
target.style.width = "calc(210px + 20px)";
target.appendChild(linkLabel);
}
}
return target;
}
async function addOverlays(canvas: HTMLCanvasElement, args: { showLogo?: boolean, logoSize?: number, logoPadding?: number }): Promise<HTMLImageElement | void> {
if (!canvas) return;
// Internal settings
const canvasPadding = 8;
const shadowBlur = 20;
const rectanglePadding = args.logoPadding || 1. / 32;
// With dropshadow under the logo
/*
const shadowColor = "#00000099";
const rectangleRadius = 0.4 * 16;
*/
// Without dropshadow under the logo
const shadowColor = "transparent";
const rectangleRadius = 0;
// Draw the website's icon in the center of the QR code
const faviconImage = new Image();
const element = document.querySelector("needle-engine") as NeedleEngineWebComponent;
const logoSrc = element?.getAttribute("loading-logo-src") || needleLogoOnlySVG;
if (!logoSrc) return;
let haveLogo = false;
if (args.showLogo !== false) {
faviconImage.src = logoSrc;
haveLogo = await new Promise((resolve, _reject) => {
faviconImage.onload = () => resolve(true);
faviconImage.onerror = (err) => {
console.error("Error loading favicon image for QR code", err);
resolve(false);
};
});
}
// Add some padding around the canvas – we need to copy the QR code image to a larger canvas
const paddedCanvas = document.createElement("canvas");
paddedCanvas.width = canvas.width + canvasPadding;
paddedCanvas.height = canvas.height + canvasPadding;
const paddedContext = paddedCanvas.getContext("2d");
if (!paddedContext) {
return;
}
// Clear with white
paddedContext.fillStyle = "#ffffff";
paddedContext.fillRect(0, 0, paddedCanvas.width, paddedCanvas.height);
paddedContext.drawImage(canvas, canvasPadding / 2, canvasPadding / 2);
// Enable anti-aliasing
paddedContext.imageSmoothingEnabled = true;
paddedContext.imageSmoothingQuality = "high";
// @ts-ignore
paddedContext.mozImageSmoothingEnabled = true;
// @ts-ignore
paddedContext.webkitImageSmoothingEnabled = true;
// Draw a slight gradient background with 10% opacity and "lighten" composite operation
paddedContext.globalCompositeOperation = "lighten";
const gradient = paddedContext.createLinearGradient(0, 0, 0, paddedCanvas.height);
gradient.addColorStop(0, "rgb(45, 45, 45)");
gradient.addColorStop(1, "rgb(45, 45, 45)");
paddedContext.fillStyle = gradient;
paddedContext.fillRect(0, 0, paddedCanvas.width, paddedCanvas.height);
paddedContext.globalCompositeOperation = "source-over";
let sizeX = Math.min(canvas.width, canvas.height) * (args.logoSize || 0.25);
let sizeY = sizeX;
if (haveLogo) {
// Get aspect of image
const aspect = faviconImage.width / faviconImage.height;
if (aspect > 1) sizeY = sizeX / aspect;
else sizeX = sizeY * aspect;
const rectanglePaddingPx = rectanglePadding * canvas.width;
// Apply padding
const sizeForBackground = Math.max(sizeX, sizeY);
const sizeXPadded = Math.round(sizeForBackground + rectanglePaddingPx);
const sizeYPadded = Math.round(sizeForBackground + rectanglePaddingPx);
const x = (paddedCanvas.width - sizeForBackground) / 2;
const y = (paddedCanvas.height - sizeForBackground) / 2;
// Draw shape with blurred shadow
paddedContext.shadowColor = shadowColor;
paddedContext.shadowBlur = shadowBlur;
// Draw rounded rectangle with radius
// Convert 0.4rem to pixels, taking DPI into account
const radius = rectangleRadius;
const xPadded = Math.round(x - rectanglePaddingPx / 2);
const yPadded = Math.round(y - rectanglePaddingPx / 2);
paddedContext.beginPath();
paddedContext.moveTo(xPadded + radius, yPadded);
paddedContext.lineTo(xPadded + sizeXPadded - radius, yPadded);
paddedContext.quadraticCurveTo(xPadded + sizeXPadded, yPadded, xPadded + sizeXPadded, yPadded + radius);
paddedContext.lineTo(xPadded + sizeXPadded, yPadded + sizeYPadded - radius);
paddedContext.quadraticCurveTo(xPadded + sizeXPadded, yPadded + sizeYPadded, xPadded + sizeXPadded - radius, yPadded + sizeYPadded);
paddedContext.lineTo(xPadded + radius, yPadded + sizeYPadded);
paddedContext.quadraticCurveTo(xPadded, yPadded + sizeYPadded, xPadded, yPadded + sizeYPadded - radius);
paddedContext.lineTo(xPadded, yPadded + radius);
paddedContext.quadraticCurveTo(xPadded, yPadded, xPadded + radius, yPadded);
paddedContext.fillStyle = "#ffffff";
paddedContext.closePath();
paddedContext.fill();
paddedContext.clip();
// Reset shadow and draw favicon
paddedContext.shadowColor = "transparent";
const logoX = (paddedCanvas.width - sizeX) / 2;
const logoY = (paddedCanvas.height - sizeY) / 2;
paddedContext.drawImage(faviconImage, logoX, logoY, sizeX, sizeY);
}
// Replace the canvas with the padded one
const paddedImage = paddedCanvas.toDataURL("image/png");
const img = document.createElement("img");
img.src = paddedImage;
img.style.width = "100%";
img.style.height = "auto";
return img;
}