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.

1,116 lines (982 loc) 43.2 kB
// 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; }