Files
dees-domtools/ts/domtools.classes.domtools.ts
T

274 lines
8.2 KiB
TypeScript

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<DomTools> | null = null;
/**
* setups domtools
*/
public static async setupDomTools(optionsArg: IDomToolsContructorOptions = {}): Promise<DomTools> {
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<IDomToolsState>('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<void>;
private readonly managedDomNodes: Element[] = [];
private readonly readyStateChangedFunc = () => {
this.tryMarkDomReady();
};
constructor(optionsArg: IDomToolsContructorOptions) {
this.initializationPromise = this.initialize();
}
private runOncePromiseMap = new Map<string, Promise<unknown>>();
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<T extends Element>(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<T>(identifierArg: string, funcArg: () => Promise<T>) {
let runOncePromise = this.runOncePromiseMap.get(identifierArg) as Promise<T> | 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<void>();
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<void>();
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;
}
}
}