import * as plugins from './domtools.plugins.js'; import { type TViewport } from './domtools.css.breakpoints.js'; import { Scroller } from './domtools.classes.scroller.js'; import { WebSetup } from '@push.rocks/websetup'; import { ThemeManager } from './domtools.classes.thememanager.js'; import { Keyboard } from './domtools.classes.keyboard.js'; declare global { var deesDomTools: DomTools | undefined; } export interface IDomToolsState { virtualViewport: TViewport; jwt: string; } export interface IDomToolsContructorOptions { ignoreGlobal?: boolean; } export class DomTools { // ====== // STATIC // ====== private static initializationPromise: Promise | null = null; /** * setups domtools */ public static async setupDomTools(optionsArg: IDomToolsContructorOptions = {}): Promise { if (optionsArg.ignoreGlobal) { const domToolsInstance = new DomTools(optionsArg); await domToolsInstance.initializationPromise; return domToolsInstance; } if (globalThis.deesDomTools && !globalThis.deesDomTools.disposed) { await globalThis.deesDomTools.initializationPromise; return globalThis.deesDomTools; } if (!DomTools.initializationPromise) { const domToolsInstance = new DomTools(optionsArg); globalThis.deesDomTools = domToolsInstance; DomTools.initializationPromise = domToolsInstance.initializationPromise .then(() => domToolsInstance) .catch((error) => { if (globalThis.deesDomTools === domToolsInstance) { globalThis.deesDomTools = undefined; } DomTools.initializationPromise = null; throw error; }); } return await DomTools.initializationPromise; } /** * if you can, use the static asysnc .setupDomTools() function instead since it is safer to use. */ public static getGlobalDomToolsSync(): DomTools { const globalDomTools = globalThis.deesDomTools; if (!globalDomTools || globalDomTools.disposed) { throw new Error('You tried to access domtools synchronously too early'); } return globalDomTools; } // ======== // INSTANCE // ======== // elements public elements: { headElement: HTMLElement | null; bodyElement: HTMLElement | null; } = { headElement: null, bodyElement: null, }; public websetup: WebSetup = new WebSetup({ metaObject: { title: '', }, }); public smartstate = new plugins.smartstate.Smartstate(); public domToolsStatePart = this.smartstate.getStatePart('domtools', { virtualViewport: 'native', jwt: '' as string, }); public router = new plugins.smartrouter.SmartRouter({ debug: false, }); public convenience = { typedrequest: plugins.typedrequest, smartdelay: plugins.smartdelay, smartjson: plugins.smartjson, smarturl: plugins.smarturl, }; public deesComms = new plugins.deesComms.DeesComms(); public scroller = new Scroller(this); public themeManager = new ThemeManager(this); public keyboard: Keyboard | null = null; // Initialized after DOM ready to avoid accessing document.body before it exists public disposed = false; public domToolsReady = plugins.smartpromise.defer(); public domReady = plugins.smartpromise.defer(); public globalStylesReady = plugins.smartpromise.defer(); private readonly initializationPromise: Promise; private readonly managedDomNodes: Element[] = []; private readonly readyStateChangedFunc = () => { this.tryMarkDomReady(); }; constructor(optionsArg: IDomToolsContructorOptions) { this.initializationPromise = this.initialize(); } private runOncePromiseMap = new Map>(); private async initialize() { this.tryMarkDomReady(); if (this.domReady.status === 'pending') { document.addEventListener('readystatechange', this.readyStateChangedFunc); } if (this.domToolsReady.status === 'pending') { this.domToolsReady.resolve(); } } private tryMarkDomReady() { if (this.disposed || this.domReady.status !== 'pending') { return; } if (document.readyState !== 'interactive' && document.readyState !== 'complete') { return; } if (!document.head || !document.body) { return; } this.elements.headElement = document.head; this.elements.bodyElement = document.body; if (!this.keyboard) { this.keyboard = new Keyboard(document.body); } document.removeEventListener('readystatechange', this.readyStateChangedFunc); this.domReady.resolve(); } private trackManagedDomNode(elementArg: T): T { this.managedDomNodes.push(elementArg); return elementArg; } private getHeadOrBodyElement() { const targetElement = this.elements.headElement || document.head || this.elements.bodyElement || document.body; if (!targetElement) { throw new Error('DomTools could not find a DOM target element to attach resources to'); } return targetElement; } /** * run a function once and always get the Promise of the first execution * @param identifierArg the indentifier arg identifies functions. functions with the same identifier are considered equal * @param funcArg the actual func arg to run */ public async runOnce(identifierArg: string, funcArg: () => Promise) { let runOncePromise = this.runOncePromiseMap.get(identifierArg) as Promise | undefined; if (!runOncePromise) { runOncePromise = Promise.resolve().then(async () => { return await funcArg(); }); this.runOncePromiseMap.set(identifierArg, runOncePromise); } return await runOncePromise; } // setStuff /** * allows to set global styles * @param stylesText the css text you want to set */ public async setGlobalStyles(stylesText: string) { await this.domReady.promise; const styleElement = this.trackManagedDomNode(document.createElement('style')); styleElement.type = 'text/css'; styleElement.appendChild(document.createTextNode(stylesText)); this.getHeadOrBodyElement().appendChild(styleElement); } /** * allows to set global styles * @param stylesText the css text you want to set */ public async setExternalScript(scriptLinkArg: string) { await this.domReady.promise; const done = plugins.smartpromise.defer(); const script = this.trackManagedDomNode(document.createElement('script')); script.src = scriptLinkArg; script.addEventListener('load', () => { done.resolve(); }); script.addEventListener('error', () => { done.reject(new Error(`Failed to load external script: ${scriptLinkArg}`)); }); this.getHeadOrBodyElement().append(script); await done.promise; } /** * allows setting external css files * @param cssLinkArg a url to an external stylesheet */ public async setExternalCss(cssLinkArg: string) { await this.domReady.promise; const done = plugins.smartpromise.defer(); const cssTag = this.trackManagedDomNode(document.createElement('link')); cssTag.rel = 'stylesheet'; cssTag.crossOrigin = 'anonymous'; cssTag.href = cssLinkArg; cssTag.addEventListener('load', () => { done.resolve(); }); cssTag.addEventListener('error', () => { done.reject(new Error(`Failed to load external stylesheet: ${cssLinkArg}`)); }); this.getHeadOrBodyElement().append(cssTag); await done.promise; } /** * allows setting of website infos * @param optionsArg the website info */ public async setWebsiteInfo(optionsArg: plugins.websetup.IWebSetupConstructorOptions) { await this.websetup.setup(optionsArg); await this.websetup.readyPromise; } public dispose() { if (this.disposed) { return; } this.disposed = true; document.removeEventListener('readystatechange', this.readyStateChangedFunc); this.keyboard?.dispose(); this.keyboard = null; this.scroller.dispose(); this.themeManager.dispose(); for (const managedDomNode of this.managedDomNodes) { managedDomNode.remove(); } this.managedDomNodes.length = 0; if (globalThis.deesDomTools === this) { globalThis.deesDomTools = undefined; DomTools.initializationPromise = null; } } }