fix(domtools): stabilize DomTools lifecycle, cleanup, and singleton behavior
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-24 - 2.5.6 - fix(domtools)
|
||||
stabilize DomTools lifecycle, cleanup, and singleton behavior
|
||||
|
||||
- make setupDomTools reliably reuse the global singleton while allowing isolated instances with ignoreGlobal
|
||||
- add dispose support across DomTools, scroller, theme manager, and keyboard to clean up listeners and managed DOM resources
|
||||
- improve runOnce to share the first in-flight promise across concurrent callers
|
||||
- make elementBasic setup wait for global style injection and expose globalStylesReady consistently
|
||||
- harden external script and stylesheet loading with domReady coordination and load/error handling
|
||||
- refine keyboard combo handling and synthetic key triggering while preserving the public key enum API
|
||||
- expand tests and documentation for singleton reuse, isolated instances, cleanup, plugin exports, and readiness behavior
|
||||
|
||||
## 2026-04-23 - 2.5.5 - fix(build)
|
||||
align test imports with tstest tapbundle and simplify the build script
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ pnpm add @design.estate/dees-domtools
|
||||
- `TypedRequest` re-exported from `@api.global/typedrequest`
|
||||
- `plugins` for direct access to the package ecosystem used internally
|
||||
|
||||
The `plugins` export keeps the commonly used downstream namespaces available, including `plugins.smartdelay`, `plugins.smartstate`, `plugins.smartpromise`, `plugins.smartrouter`, `plugins.smartrx`, `plugins.smarturl`, and `plugins.typedrequest`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
@@ -37,6 +39,8 @@ console.log(domtools.elements.bodyElement);
|
||||
|
||||
`setupDomTools()` is safe to call repeatedly. By default it returns a shared global instance and avoids duplicate initialization work.
|
||||
|
||||
If you need an isolated instance for testing or short-lived usage, pass `ignoreGlobal: true`. Isolated instances follow the same `domToolsReady` and `domReady` lifecycle as the shared singleton.
|
||||
|
||||
## DomTools Lifecycle
|
||||
|
||||
```ts
|
||||
@@ -50,6 +54,8 @@ await domtools.domToolsReady.promise;
|
||||
await domtools.domReady.promise;
|
||||
```
|
||||
|
||||
`setupDomTools()` resolves once the instance is initialized and its readiness listeners are installed. `domReady` resolves later, once `document.head` and `document.body` are available.
|
||||
|
||||
Main instance properties:
|
||||
|
||||
- `elements.headElement` and `elements.bodyElement`
|
||||
@@ -64,6 +70,22 @@ Main instance properties:
|
||||
|
||||
If you need the already-created global instance synchronously, use `DomTools.getGlobalDomToolsSync()` after startup has completed.
|
||||
|
||||
## Cleanup
|
||||
|
||||
```ts
|
||||
import { DomTools } from '@design.estate/dees-domtools';
|
||||
|
||||
const domtools = await DomTools.setupDomTools({
|
||||
ignoreGlobal: true,
|
||||
});
|
||||
|
||||
// ...use the instance
|
||||
|
||||
domtools.dispose();
|
||||
```
|
||||
|
||||
`dispose()` removes the listeners and DOM resources owned by that `DomTools` instance. For shared global usage you usually keep the singleton alive for the lifetime of the page, but disposal is useful for tests and intentionally short-lived isolated instances.
|
||||
|
||||
## DOM, CSS, and External Resources
|
||||
|
||||
```ts
|
||||
@@ -122,6 +144,8 @@ await themeManager.enableAutomaticGlobalThemeChange();
|
||||
|
||||
The theme manager starts from `prefers-color-scheme` and publishes updates through an RxJS `ReplaySubject<boolean>`.
|
||||
|
||||
`enableAutomaticGlobalThemeChange()` waits for `domReady`, so it is safe to call before `document.body` exists.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
The keyboard helper is created after `document.body` exists, so wait for `domReady` before using it.
|
||||
@@ -249,6 +273,8 @@ class DemoElement extends LitElement {
|
||||
|
||||
`elementBasic.setup()` performs the shared `DomTools` setup and injects the package's global base styles once.
|
||||
|
||||
The returned promise resolves after the shared base styles have been injected, and `domtools.globalStylesReady` resolves at the same point.
|
||||
|
||||
## State and One-Time Work
|
||||
|
||||
`domToolsStatePart` starts with this shape:
|
||||
|
||||
+114
-3
@@ -1,9 +1,120 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as domtools from '../ts/index.js';
|
||||
|
||||
tap.test('first test', async () => {
|
||||
const domtoolsInstance = await domtools.DomTools.setupDomTools();
|
||||
expect(domtoolsInstance).toBeInstanceOf(domtools.DomTools);
|
||||
const cleanupGlobalDomTools = () => {
|
||||
try {
|
||||
domtools.DomTools.getGlobalDomToolsSync().dispose();
|
||||
} catch {
|
||||
// No global domtools instance exists yet.
|
||||
}
|
||||
};
|
||||
|
||||
const getStyleCount = () => document.head.querySelectorAll('style').length;
|
||||
|
||||
tap.test('setupDomTools reuses the global singleton', async () => {
|
||||
cleanupGlobalDomTools();
|
||||
const firstInstance = await domtools.DomTools.setupDomTools();
|
||||
const secondInstance = await domtools.DomTools.setupDomTools();
|
||||
|
||||
expect(firstInstance).toBeInstanceOf(domtools.DomTools);
|
||||
expect(firstInstance === secondInstance).toBeTrue();
|
||||
expect(domtools.DomTools.getGlobalDomToolsSync() === firstInstance).toBeTrue();
|
||||
|
||||
cleanupGlobalDomTools();
|
||||
});
|
||||
|
||||
tap.test('ignoreGlobal creates an isolated instance without replacing the global one', async () => {
|
||||
cleanupGlobalDomTools();
|
||||
const globalInstance = await domtools.DomTools.setupDomTools();
|
||||
const isolatedInstance = await domtools.DomTools.setupDomTools({
|
||||
ignoreGlobal: true,
|
||||
});
|
||||
|
||||
await globalInstance.domToolsReady.promise;
|
||||
await globalInstance.domReady.promise;
|
||||
await isolatedInstance.domToolsReady.promise;
|
||||
await isolatedInstance.domReady.promise;
|
||||
|
||||
expect(globalInstance === isolatedInstance).toBeFalse();
|
||||
expect(domtools.DomTools.getGlobalDomToolsSync() === globalInstance).toBeTrue();
|
||||
expect(Boolean(isolatedInstance.keyboard)).toBeTrue();
|
||||
|
||||
isolatedInstance.dispose();
|
||||
cleanupGlobalDomTools();
|
||||
});
|
||||
|
||||
tap.test('runOnce shares the first result across concurrent callers', async () => {
|
||||
cleanupGlobalDomTools();
|
||||
const domtoolsInstance = await domtools.DomTools.setupDomTools({
|
||||
ignoreGlobal: true,
|
||||
});
|
||||
let runCount = 0;
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
domtoolsInstance.runOnce('test-run-once', async () => {
|
||||
runCount += 1;
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 10));
|
||||
return 'shared-result';
|
||||
}),
|
||||
domtoolsInstance.runOnce('test-run-once', async () => {
|
||||
runCount += 1;
|
||||
return 'unexpected-result';
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(runCount).toEqual(1);
|
||||
expect(firstResult).toEqual('shared-result');
|
||||
expect(secondResult).toEqual('shared-result');
|
||||
|
||||
domtoolsInstance.dispose();
|
||||
});
|
||||
|
||||
tap.test('elementBasic setup waits for global styles and keeps injection idempotent', async () => {
|
||||
cleanupGlobalDomTools();
|
||||
const styleCountBefore = getStyleCount();
|
||||
|
||||
const firstInstance = await domtools.elementBasic.setup();
|
||||
const secondInstance = await domtools.elementBasic.setup();
|
||||
|
||||
await firstInstance.globalStylesReady.promise;
|
||||
|
||||
expect(firstInstance === secondInstance).toBeTrue();
|
||||
expect(getStyleCount() - styleCountBefore).toEqual(1);
|
||||
|
||||
firstInstance.dispose();
|
||||
expect(getStyleCount()).toEqual(styleCountBefore);
|
||||
expect(() => domtools.DomTools.getGlobalDomToolsSync()).toThrow();
|
||||
});
|
||||
|
||||
tap.test('keyboard combos still emit through the public key enum API', async () => {
|
||||
const domtoolsInstance = await domtools.DomTools.setupDomTools({
|
||||
ignoreGlobal: true,
|
||||
});
|
||||
await domtoolsInstance.domReady.promise;
|
||||
|
||||
const keyboard = domtoolsInstance.keyboard!;
|
||||
const events: KeyboardEvent[] = [];
|
||||
const subscription = keyboard.on([keyboard.keyEnum.Ctrl, keyboard.keyEnum.S]).subscribe((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
keyboard.triggerKeyPress([keyboard.keyEnum.Ctrl, keyboard.keyEnum.S]);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].key).toEqual('s');
|
||||
expect(events[0].ctrlKey).toBeTrue();
|
||||
|
||||
subscription.unsubscribe();
|
||||
domtoolsInstance.dispose();
|
||||
});
|
||||
|
||||
tap.test('plugin namespace exports remain available for downstream consumers', async () => {
|
||||
expect(domtools.plugins.smartdelay.delayFor).toBeDefined();
|
||||
expect(domtools.plugins.smartstate.Smartstate).toBeDefined();
|
||||
expect(domtools.plugins.smartpromise.defer).toBeDefined();
|
||||
expect(domtools.plugins.smartrouter.SmartRouter).toBeDefined();
|
||||
expect(domtools.plugins.smartrx.rxjs.Subject).toBeDefined();
|
||||
expect(domtools.plugins.smarturl.Smarturl).toBeDefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-domtools',
|
||||
version: '2.5.5',
|
||||
version: '2.5.6',
|
||||
description: 'A package providing tools to simplify complex CSS structures and web development tasks, featuring TypeScript support and integration with various web technologies.'
|
||||
}
|
||||
|
||||
+137
-82
@@ -5,6 +5,10 @@ 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;
|
||||
@@ -24,59 +28,43 @@ export class DomTools {
|
||||
* setups domtools
|
||||
*/
|
||||
public static async setupDomTools(optionsArg: IDomToolsContructorOptions = {}): Promise<DomTools> {
|
||||
// If initialization is already in progress and we're not ignoring global, wait for it
|
||||
if (!optionsArg.ignoreGlobal && DomTools.initializationPromise) {
|
||||
return await DomTools.initializationPromise;
|
||||
}
|
||||
|
||||
// Create initialization promise to prevent race conditions
|
||||
if (!optionsArg.ignoreGlobal) {
|
||||
DomTools.initializationPromise = (async () => {
|
||||
let domToolsInstance: DomTools;
|
||||
if (!globalThis.deesDomTools) {
|
||||
globalThis.deesDomTools = new DomTools(optionsArg);
|
||||
domToolsInstance = globalThis.deesDomTools;
|
||||
|
||||
// lets make sure the dom is ready
|
||||
const readyStateChangedFunc = () => {
|
||||
if (document.readyState === 'interactive' || document.readyState === 'complete') {
|
||||
domToolsInstance.elements.headElement = document.querySelector('head');
|
||||
domToolsInstance.elements.bodyElement = document.querySelector('body');
|
||||
// Initialize keyboard now that document.body exists
|
||||
domToolsInstance.keyboard = new Keyboard(document.body);
|
||||
domToolsInstance.domReady.resolve();
|
||||
}
|
||||
};
|
||||
// Check current state immediately to avoid race condition
|
||||
if (document.readyState === 'interactive' || document.readyState === 'complete') {
|
||||
readyStateChangedFunc();
|
||||
} else {
|
||||
document.addEventListener('readystatechange', readyStateChangedFunc);
|
||||
}
|
||||
domToolsInstance.domToolsReady.resolve();
|
||||
} else {
|
||||
domToolsInstance = globalThis.deesDomTools;
|
||||
}
|
||||
await domToolsInstance.domToolsReady.promise;
|
||||
return domToolsInstance;
|
||||
})();
|
||||
return await DomTools.initializationPromise;
|
||||
} else {
|
||||
// ignoreGlobal case - create isolated instance
|
||||
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: DomTools = globalThis.deesDomTools;
|
||||
if (!globalDomTools) {
|
||||
const globalDomTools = globalThis.deesDomTools;
|
||||
if (!globalDomTools || globalDomTools.disposed) {
|
||||
throw new Error('You tried to access domtools synchronously too early');
|
||||
}
|
||||
return globalThis.deesDomTools;
|
||||
return globalDomTools;
|
||||
}
|
||||
|
||||
// ========
|
||||
@@ -118,16 +106,68 @@ export class DomTools {
|
||||
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();
|
||||
|
||||
constructor(optionsArg: IDomToolsContructorOptions) {}
|
||||
private readonly initializationPromise: Promise<void>;
|
||||
private readonly managedDomNodes: Element[] = [];
|
||||
private readonly readyStateChangedFunc = () => {
|
||||
this.tryMarkDomReady();
|
||||
};
|
||||
|
||||
private runOnceTrackerStringMap = new plugins.lik.Stringmap();
|
||||
private runOnceResultMap = new plugins.lik.FastMap();
|
||||
private runOnceErrorMap = new plugins.lik.FastMap();
|
||||
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
|
||||
@@ -135,34 +175,14 @@ export class DomTools {
|
||||
* @param funcArg the actual func arg to run
|
||||
*/
|
||||
public async runOnce<T>(identifierArg: string, funcArg: () => Promise<T>) {
|
||||
const runningId = `${identifierArg}+runningCheck`;
|
||||
if (!this.runOnceTrackerStringMap.checkString(identifierArg)) {
|
||||
this.runOnceTrackerStringMap.addString(identifierArg);
|
||||
this.runOnceTrackerStringMap.addString(runningId);
|
||||
try {
|
||||
const result = await funcArg();
|
||||
this.runOnceResultMap.addToMap(identifierArg, result);
|
||||
} catch (error) {
|
||||
// Store error so waiting callers can receive it
|
||||
this.runOnceErrorMap.addToMap(identifierArg, error);
|
||||
} finally {
|
||||
// Always remove running flag to prevent permanent stuck state
|
||||
this.runOnceTrackerStringMap.removeString(runningId);
|
||||
}
|
||||
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 this.runOnceTrackerStringMap.registerUntilTrue(
|
||||
(stringMap?: string[]) => {
|
||||
return !stringMap?.includes(runningId);
|
||||
},
|
||||
() => {
|
||||
// Check if there was an error and re-throw it
|
||||
const error = this.runOnceErrorMap.getByKey(identifierArg);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return this.runOnceResultMap.getByKey(identifierArg);
|
||||
}
|
||||
);
|
||||
return await runOncePromise;
|
||||
}
|
||||
|
||||
// setStuff
|
||||
@@ -172,10 +192,10 @@ export class DomTools {
|
||||
*/
|
||||
public async setGlobalStyles(stylesText: string) {
|
||||
await this.domReady.promise;
|
||||
const styleElement = document.createElement('style');
|
||||
const styleElement = this.trackManagedDomNode(document.createElement('style'));
|
||||
styleElement.type = 'text/css';
|
||||
styleElement.appendChild(document.createTextNode(stylesText));
|
||||
this.elements.headElement!.appendChild(styleElement);
|
||||
this.getHeadOrBodyElement().appendChild(styleElement);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,14 +204,16 @@ export class DomTools {
|
||||
*/
|
||||
public async setExternalScript(scriptLinkArg: string) {
|
||||
await this.domReady.promise;
|
||||
const done = plugins.smartpromise.defer();
|
||||
const script = document.createElement('script');
|
||||
const done = plugins.smartpromise.defer<void>();
|
||||
const script = this.trackManagedDomNode(document.createElement('script'));
|
||||
script.src = scriptLinkArg;
|
||||
script.addEventListener('load', function () {
|
||||
script.addEventListener('load', () => {
|
||||
done.resolve();
|
||||
});
|
||||
const parentNode = document.head || document.body;
|
||||
parentNode.append(script);
|
||||
script.addEventListener('error', () => {
|
||||
done.reject(new Error(`Failed to load external script: ${scriptLinkArg}`));
|
||||
});
|
||||
this.getHeadOrBodyElement().append(script);
|
||||
await done.promise;
|
||||
}
|
||||
|
||||
@@ -200,11 +222,20 @@ export class DomTools {
|
||||
* @param cssLinkArg a url to an external stylesheet
|
||||
*/
|
||||
public async setExternalCss(cssLinkArg: string) {
|
||||
const cssTag = document.createElement('link');
|
||||
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;
|
||||
document.head.append(cssTag);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,4 +246,28 @@ export class DomTools {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+374
-15
@@ -129,8 +129,15 @@ export enum Key {
|
||||
}
|
||||
|
||||
export class Keyboard {
|
||||
private mapCombosToHandlers = new Map<number[], plugins.smartrx.rxjs.Subject<KeyboardEvent>>();
|
||||
private mapCombosToHandlers = new Map<
|
||||
string,
|
||||
{
|
||||
keys: Key[];
|
||||
subjects: plugins.smartrx.rxjs.Subject<KeyboardEvent>[];
|
||||
}
|
||||
>();
|
||||
private pressedKeys = new Set<Key>();
|
||||
private listening = false;
|
||||
|
||||
constructor(private domNode: Element | Document) {
|
||||
this.startListening();
|
||||
@@ -145,46 +152,74 @@ export class Keyboard {
|
||||
}
|
||||
|
||||
public triggerKeyPress(keysArg: Key[]) {
|
||||
for (const key of keysArg) {
|
||||
const normalizedKeys = this.normalizeKeys(keysArg);
|
||||
for (const key of normalizedKeys) {
|
||||
this.pressedKeys.add(key);
|
||||
}
|
||||
this.checkMatchingKeyboardSubjects();
|
||||
for (const key of keysArg) {
|
||||
this.checkMatchingKeyboardSubjects(this.createKeyboardEvent(normalizedKeys));
|
||||
for (const key of normalizedKeys) {
|
||||
this.pressedKeys.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
public startListening() {
|
||||
if (this.listening) {
|
||||
return;
|
||||
}
|
||||
this.domNode.addEventListener('keydown', this.handleKeyDown as EventListener);
|
||||
this.domNode.addEventListener('keyup', this.handleKeyUp as EventListener);
|
||||
this.listening = true;
|
||||
}
|
||||
|
||||
public stopListening() {
|
||||
if (!this.listening) {
|
||||
return;
|
||||
}
|
||||
this.domNode.removeEventListener('keydown', this.handleKeyDown as EventListener);
|
||||
this.domNode.removeEventListener('keyup', this.handleKeyUp as EventListener);
|
||||
this.listening = false;
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.stopListening();
|
||||
for (const comboEntry of this.mapCombosToHandlers.values()) {
|
||||
for (const subject of comboEntry.subjects) {
|
||||
subject.complete();
|
||||
}
|
||||
}
|
||||
this.mapCombosToHandlers.clear();
|
||||
this.pressedKeys.clear();
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
private handleKeyDown = (event: KeyboardEvent) => {
|
||||
this.pressedKeys.add(event.keyCode);
|
||||
const resolvedKey = this.resolveKey(event);
|
||||
if (resolvedKey === null) {
|
||||
return;
|
||||
}
|
||||
this.pressedKeys.add(resolvedKey);
|
||||
this.checkMatchingKeyboardSubjects(event);
|
||||
};
|
||||
|
||||
private checkMatchingKeyboardSubjects(payloadArg?) {
|
||||
this.mapCombosToHandlers.forEach((subjectArg, keysArg) => {
|
||||
if (this.areAllKeysPressed(keysArg)) {
|
||||
subjectArg.next(payloadArg);
|
||||
private checkMatchingKeyboardSubjects(payloadArg: KeyboardEvent) {
|
||||
this.mapCombosToHandlers.forEach((comboEntry) => {
|
||||
if (this.areAllKeysPressed(comboEntry.keys)) {
|
||||
for (const subjectArg of comboEntry.subjects) {
|
||||
subjectArg.next(payloadArg);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleKeyUp = (event: KeyboardEvent) => {
|
||||
this.pressedKeys.delete(event.keyCode);
|
||||
const resolvedKey = this.resolveKey(event);
|
||||
if (resolvedKey === null) {
|
||||
return;
|
||||
}
|
||||
this.pressedKeys.delete(resolvedKey);
|
||||
};
|
||||
|
||||
private areAllKeysPressed(keysArg: Key[]) {
|
||||
@@ -203,11 +238,335 @@ export class Keyboard {
|
||||
keysArg: Array<Key>,
|
||||
subjectArg: plugins.smartrx.rxjs.Subject<KeyboardEvent>
|
||||
) {
|
||||
if (!this.mapCombosToHandlers.has(keysArg)) {
|
||||
this.mapCombosToHandlers.set(keysArg, subjectArg);
|
||||
} else {
|
||||
const subject = this.mapCombosToHandlers.get(keysArg);
|
||||
return subject;
|
||||
const normalizedKeys = this.normalizeKeys(keysArg);
|
||||
const comboKey = normalizedKeys.join('+');
|
||||
const existingEntry = this.mapCombosToHandlers.get(comboKey);
|
||||
if (!existingEntry) {
|
||||
this.mapCombosToHandlers.set(comboKey, {
|
||||
keys: normalizedKeys,
|
||||
subjects: [subjectArg],
|
||||
});
|
||||
return;
|
||||
}
|
||||
existingEntry.subjects.push(subjectArg);
|
||||
}
|
||||
|
||||
private normalizeKeys(keysArg: Key[]) {
|
||||
return [...new Set(keysArg)].sort((leftKey, rightKey) => leftKey - rightKey);
|
||||
}
|
||||
|
||||
private resolveKey(event: KeyboardEvent): Key | null {
|
||||
return this.resolveKeyFromCode(event.code)
|
||||
?? this.resolveKeyFromKey(event.key)
|
||||
?? this.resolveKeyFromKeyCode(event.keyCode);
|
||||
}
|
||||
|
||||
private resolveKeyFromCode(codeArg: string): Key | null {
|
||||
if (!codeArg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (codeArg.startsWith('Key') && codeArg.length === 4) {
|
||||
return Key[codeArg.slice(3) as keyof typeof Key] ?? null;
|
||||
}
|
||||
|
||||
if (codeArg.startsWith('Digit') && codeArg.length === 6) {
|
||||
return this.resolveKeyFromKey(codeArg.slice(5));
|
||||
}
|
||||
|
||||
switch (codeArg) {
|
||||
case 'Backspace':
|
||||
return Key.Backspace;
|
||||
case 'Tab':
|
||||
return Key.Tab;
|
||||
case 'Enter':
|
||||
case 'NumpadEnter':
|
||||
return Key.Enter;
|
||||
case 'ShiftLeft':
|
||||
case 'ShiftRight':
|
||||
return Key.Shift;
|
||||
case 'ControlLeft':
|
||||
case 'ControlRight':
|
||||
return Key.Ctrl;
|
||||
case 'AltLeft':
|
||||
case 'AltRight':
|
||||
return Key.Alt;
|
||||
case 'Pause':
|
||||
return Key.PauseBreak;
|
||||
case 'CapsLock':
|
||||
return Key.CapsLock;
|
||||
case 'Escape':
|
||||
return Key.Escape;
|
||||
case 'Space':
|
||||
return Key.Space;
|
||||
case 'PageUp':
|
||||
return Key.PageUp;
|
||||
case 'PageDown':
|
||||
return Key.PageDown;
|
||||
case 'End':
|
||||
return Key.End;
|
||||
case 'Home':
|
||||
return Key.Home;
|
||||
case 'ArrowLeft':
|
||||
return Key.LeftArrow;
|
||||
case 'ArrowUp':
|
||||
return Key.UpArrow;
|
||||
case 'ArrowRight':
|
||||
return Key.RightArrow;
|
||||
case 'ArrowDown':
|
||||
return Key.DownArrow;
|
||||
case 'Insert':
|
||||
return Key.Insert;
|
||||
case 'Delete':
|
||||
return Key.Delete;
|
||||
case 'MetaLeft':
|
||||
return Key.LeftWindowKey;
|
||||
case 'MetaRight':
|
||||
return Key.RightWindowKey;
|
||||
case 'ContextMenu':
|
||||
return Key.SelectKey;
|
||||
case 'Numpad0':
|
||||
return Key.Numpad0;
|
||||
case 'Numpad1':
|
||||
return Key.Numpad1;
|
||||
case 'Numpad2':
|
||||
return Key.Numpad2;
|
||||
case 'Numpad3':
|
||||
return Key.Numpad3;
|
||||
case 'Numpad4':
|
||||
return Key.Numpad4;
|
||||
case 'Numpad5':
|
||||
return Key.Numpad5;
|
||||
case 'Numpad6':
|
||||
return Key.Numpad6;
|
||||
case 'Numpad7':
|
||||
return Key.Numpad7;
|
||||
case 'Numpad8':
|
||||
return Key.Numpad8;
|
||||
case 'Numpad9':
|
||||
return Key.Numpad9;
|
||||
case 'NumpadMultiply':
|
||||
return Key.Multiply;
|
||||
case 'NumpadAdd':
|
||||
return Key.Add;
|
||||
case 'NumpadSubtract':
|
||||
return Key.Subtract;
|
||||
case 'NumpadDecimal':
|
||||
return Key.DecimalPoint;
|
||||
case 'NumpadDivide':
|
||||
return Key.Divide;
|
||||
case 'F1':
|
||||
return Key.F1;
|
||||
case 'F2':
|
||||
return Key.F2;
|
||||
case 'F3':
|
||||
return Key.F3;
|
||||
case 'F4':
|
||||
return Key.F4;
|
||||
case 'F5':
|
||||
return Key.F5;
|
||||
case 'F6':
|
||||
return Key.F6;
|
||||
case 'F7':
|
||||
return Key.F7;
|
||||
case 'F8':
|
||||
return Key.F8;
|
||||
case 'F9':
|
||||
return Key.F9;
|
||||
case 'F10':
|
||||
return Key.F10;
|
||||
case 'F11':
|
||||
return Key.F11;
|
||||
case 'F12':
|
||||
return Key.F12;
|
||||
case 'NumLock':
|
||||
return Key.NumLock;
|
||||
case 'ScrollLock':
|
||||
return Key.ScrollLock;
|
||||
case 'Semicolon':
|
||||
return Key.SemiColon;
|
||||
case 'Equal':
|
||||
return Key.Equals;
|
||||
case 'Comma':
|
||||
return Key.Comma;
|
||||
case 'Minus':
|
||||
return Key.Dash;
|
||||
case 'Period':
|
||||
return Key.Period;
|
||||
case 'Slash':
|
||||
return Key.ForwardSlash;
|
||||
case 'Backquote':
|
||||
return Key.Tilde;
|
||||
case 'BracketLeft':
|
||||
return Key.OpenBracket;
|
||||
case 'BracketRight':
|
||||
return Key.ClosedBracket;
|
||||
case 'Quote':
|
||||
return Key.Quote;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveKeyFromKey(keyArg: string): Key | null {
|
||||
if (!keyArg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (keyArg.length === 1) {
|
||||
const upperKey = keyArg.toUpperCase();
|
||||
if (upperKey >= 'A' && upperKey <= 'Z') {
|
||||
return Key[upperKey as keyof typeof Key] ?? null;
|
||||
}
|
||||
|
||||
switch (keyArg) {
|
||||
case '0':
|
||||
return Key.Zero;
|
||||
case '1':
|
||||
return Key.One;
|
||||
case '2':
|
||||
return Key.Two;
|
||||
case '3':
|
||||
return Key.Three;
|
||||
case '4':
|
||||
return Key.Four;
|
||||
case '5':
|
||||
return Key.Five;
|
||||
case '6':
|
||||
return Key.Six;
|
||||
case '7':
|
||||
return Key.Seven;
|
||||
case '8':
|
||||
return Key.Eight;
|
||||
case '9':
|
||||
return Key.Nine;
|
||||
case ' ':
|
||||
return Key.Space;
|
||||
case ';':
|
||||
return Key.SemiColon;
|
||||
case '=':
|
||||
return Key.Equals;
|
||||
case ',':
|
||||
return Key.Comma;
|
||||
case '-':
|
||||
return Key.Dash;
|
||||
case '.':
|
||||
return Key.Period;
|
||||
case '/':
|
||||
return Key.ForwardSlash;
|
||||
case '`':
|
||||
return Key.Tilde;
|
||||
case '[':
|
||||
return Key.OpenBracket;
|
||||
case ']':
|
||||
return Key.ClosedBracket;
|
||||
case '\'':
|
||||
return Key.Quote;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
switch (keyArg) {
|
||||
case 'Backspace':
|
||||
return Key.Backspace;
|
||||
case 'Tab':
|
||||
return Key.Tab;
|
||||
case 'Enter':
|
||||
return Key.Enter;
|
||||
case 'Shift':
|
||||
return Key.Shift;
|
||||
case 'Control':
|
||||
return Key.Ctrl;
|
||||
case 'Alt':
|
||||
return Key.Alt;
|
||||
case 'Pause':
|
||||
return Key.PauseBreak;
|
||||
case 'CapsLock':
|
||||
return Key.CapsLock;
|
||||
case 'Escape':
|
||||
return Key.Escape;
|
||||
case 'PageUp':
|
||||
return Key.PageUp;
|
||||
case 'PageDown':
|
||||
return Key.PageDown;
|
||||
case 'End':
|
||||
return Key.End;
|
||||
case 'Home':
|
||||
return Key.Home;
|
||||
case 'ArrowLeft':
|
||||
return Key.LeftArrow;
|
||||
case 'ArrowUp':
|
||||
return Key.UpArrow;
|
||||
case 'ArrowRight':
|
||||
return Key.RightArrow;
|
||||
case 'ArrowDown':
|
||||
return Key.DownArrow;
|
||||
case 'Insert':
|
||||
return Key.Insert;
|
||||
case 'Delete':
|
||||
return Key.Delete;
|
||||
case 'Meta':
|
||||
return Key.LeftWindowKey;
|
||||
case 'ContextMenu':
|
||||
return Key.SelectKey;
|
||||
case 'NumLock':
|
||||
return Key.NumLock;
|
||||
case 'ScrollLock':
|
||||
return Key.ScrollLock;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveKeyFromKeyCode(keyCodeArg: number): Key | null {
|
||||
if (typeof keyCodeArg !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return typeof Key[keyCodeArg as Key] !== 'undefined' ? (keyCodeArg as Key) : null;
|
||||
}
|
||||
|
||||
private createKeyboardEvent(keysArg: Key[]) {
|
||||
const triggerKey = keysArg[keysArg.length - 1] ?? Key.Enter;
|
||||
const keyDescriptor = this.getKeyDescriptor(triggerKey);
|
||||
return new KeyboardEvent('keydown', {
|
||||
key: keyDescriptor.key,
|
||||
code: keyDescriptor.code,
|
||||
ctrlKey: keysArg.includes(Key.Ctrl),
|
||||
shiftKey: keysArg.includes(Key.Shift),
|
||||
altKey: keysArg.includes(Key.Alt),
|
||||
metaKey: keysArg.includes(Key.LeftWindowKey) || keysArg.includes(Key.RightWindowKey),
|
||||
});
|
||||
}
|
||||
|
||||
private getKeyDescriptor(keyArg: Key): { key: string; code: string } {
|
||||
switch (keyArg) {
|
||||
case Key.Ctrl:
|
||||
return { key: 'Control', code: 'ControlLeft' };
|
||||
case Key.Shift:
|
||||
return { key: 'Shift', code: 'ShiftLeft' };
|
||||
case Key.Alt:
|
||||
return { key: 'Alt', code: 'AltLeft' };
|
||||
case Key.Enter:
|
||||
return { key: 'Enter', code: 'Enter' };
|
||||
case Key.Space:
|
||||
return { key: ' ', code: 'Space' };
|
||||
default: {
|
||||
const keyName = Key[keyArg];
|
||||
if (keyName && keyName.length === 1) {
|
||||
if (keyName >= 'A' && keyName <= 'Z') {
|
||||
return { key: keyName.toLowerCase(), code: `Key${keyName}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (keyArg >= Key.Zero && keyArg <= Key.Nine) {
|
||||
const digit = String(keyArg - Key.Zero);
|
||||
return { key: digit, code: `Digit${digit}` };
|
||||
}
|
||||
|
||||
return { key: keyName ?? 'Unidentified', code: 'Unidentified' };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,22 @@ import * as plugins from './domtools.plugins.js';
|
||||
|
||||
export class Scroller {
|
||||
public domtoolsInstance: DomTools;
|
||||
private disposed = false;
|
||||
|
||||
// Array to store scroll callback functions.
|
||||
private scrollCallbacks: Array<() => void> = [];
|
||||
|
||||
// Lenis instance (if activated) or null.
|
||||
private lenisInstance: plugins.Lenis | null = null;
|
||||
private lenisScrollUnsubscribe: (() => void) | null = null;
|
||||
private nativeScrollListenerAttached = false;
|
||||
|
||||
// Bound handlers to allow removal from event listeners.
|
||||
private handleNativeScroll = (event: Event): void => {
|
||||
private handleNativeScroll = (_event: Event): void => {
|
||||
this.executeScrollCallbacks();
|
||||
};
|
||||
|
||||
private handleLenisScroll = (info: any): void => {
|
||||
private handleLenisScroll = (_info: plugins.Lenis): void => {
|
||||
this.executeScrollCallbacks();
|
||||
};
|
||||
|
||||
@@ -44,23 +47,40 @@ export class Scroller {
|
||||
* Detects whether native smooth scrolling is enabled.
|
||||
*/
|
||||
public async detectNativeSmoothScroll() {
|
||||
const rootScrollBehavior = getComputedStyle(document.documentElement).scrollBehavior;
|
||||
const bodyScrollBehavior = document.body ? getComputedStyle(document.body).scrollBehavior : 'auto';
|
||||
if (rootScrollBehavior === 'smooth' || bodyScrollBehavior === 'smooth') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const done = plugins.smartpromise.defer<boolean>();
|
||||
const sampleSize = 100;
|
||||
const sampleSize = 12;
|
||||
const acceptableDeltaDifference = 3;
|
||||
const minimumSmoothRatio = 0.75;
|
||||
const timeoutInMs = 1200;
|
||||
|
||||
const eventDeltas: number[] = [];
|
||||
|
||||
const finalize = (result: boolean) => {
|
||||
window.removeEventListener('wheel', onWheel);
|
||||
window.clearTimeout(timeoutId);
|
||||
done.resolve(result);
|
||||
};
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
eventDeltas.push(event.deltaY);
|
||||
|
||||
if (eventDeltas.length >= sampleSize) {
|
||||
window.removeEventListener('wheel', onWheel);
|
||||
analyzeEvents();
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeEvents() {
|
||||
if (eventDeltas.length < 2) {
|
||||
finalize(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalDiffs = eventDeltas.length - 1;
|
||||
let smallDiffCount = 0;
|
||||
|
||||
@@ -72,16 +92,14 @@ export class Scroller {
|
||||
}
|
||||
|
||||
const smoothRatio = smallDiffCount / totalDiffs;
|
||||
if (smoothRatio >= minimumSmoothRatio) {
|
||||
console.log('Smooth scrolling detected.');
|
||||
done.resolve(true);
|
||||
} else {
|
||||
console.log('Smooth scrolling NOT detected.');
|
||||
done.resolve(false);
|
||||
}
|
||||
finalize(smoothRatio >= minimumSmoothRatio);
|
||||
}
|
||||
|
||||
window.addEventListener('wheel', onWheel);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
analyzeEvents();
|
||||
}, timeoutInMs);
|
||||
|
||||
window.addEventListener('wheel', onWheel, { passive: true });
|
||||
return done.promise;
|
||||
}
|
||||
|
||||
@@ -91,6 +109,14 @@ export class Scroller {
|
||||
* Lenis will be destroyed immediately.
|
||||
*/
|
||||
public async enableLenisScroll(optionsArg?: { disableOnNativeSmoothScroll?: boolean }) {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.lenisInstance) {
|
||||
this.disableLenisScroll();
|
||||
}
|
||||
|
||||
const lenis = new plugins.Lenis({
|
||||
autoRaf: true,
|
||||
});
|
||||
@@ -107,24 +133,28 @@ export class Scroller {
|
||||
// Switch from native scroll listener to Lenis scroll listener.
|
||||
this.detachNativeScrollListener();
|
||||
this.attachLenisScrollListener();
|
||||
}
|
||||
|
||||
// Monkey-patch the destroy method so that when Lenis is destroyed,
|
||||
// the native scroll listener is reattached.
|
||||
const originalDestroy = lenis.destroy.bind(lenis);
|
||||
lenis.destroy = () => {
|
||||
originalDestroy();
|
||||
this.detachLenisScrollListener();
|
||||
this.attachNativeScrollListener();
|
||||
this.lenisInstance = null;
|
||||
};
|
||||
public disableLenisScroll() {
|
||||
if (!this.lenisInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.detachLenisScrollListener();
|
||||
this.lenisInstance.destroy();
|
||||
this.lenisInstance = null;
|
||||
this.attachNativeScrollListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a callback to be executed on scroll.
|
||||
* @param callback A function to execute on each scroll event.
|
||||
*/
|
||||
public onScroll(callback: () => void): void {
|
||||
public onScroll(callback: () => void): () => void {
|
||||
this.scrollCallbacks.push(callback);
|
||||
return () => {
|
||||
this.scrollCallbacks = this.scrollCallbacks.filter((existingCallback) => existingCallback !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,23 +175,30 @@ export class Scroller {
|
||||
* Attaches the native scroll event listener.
|
||||
*/
|
||||
private attachNativeScrollListener(): void {
|
||||
if (this.nativeScrollListenerAttached || this.disposed) {
|
||||
return;
|
||||
}
|
||||
window.addEventListener('scroll', this.handleNativeScroll);
|
||||
this.nativeScrollListenerAttached = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaches the native scroll event listener.
|
||||
*/
|
||||
private detachNativeScrollListener(): void {
|
||||
if (!this.nativeScrollListenerAttached) {
|
||||
return;
|
||||
}
|
||||
window.removeEventListener('scroll', this.handleNativeScroll);
|
||||
this.nativeScrollListenerAttached = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the Lenis scroll event listener.
|
||||
*/
|
||||
private attachLenisScrollListener(): void {
|
||||
if (this.lenisInstance) {
|
||||
// Assuming that Lenis exposes an `on` method to listen to scroll events.
|
||||
this.lenisInstance.on('scroll', this.handleLenisScroll);
|
||||
if (this.lenisInstance && !this.lenisScrollUnsubscribe) {
|
||||
this.lenisScrollUnsubscribe = this.lenisInstance.on('scroll', this.handleLenisScroll);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,9 +206,23 @@ export class Scroller {
|
||||
* Detaches the Lenis scroll event listener.
|
||||
*/
|
||||
private detachLenisScrollListener(): void {
|
||||
if (this.lenisInstance) {
|
||||
// Assuming that Lenis exposes an `off` method to remove scroll event listeners.
|
||||
this.lenisInstance.off('scroll', this.handleLenisScroll);
|
||||
if (this.lenisScrollUnsubscribe) {
|
||||
this.lenisScrollUnsubscribe();
|
||||
this.lenisScrollUnsubscribe = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disposed = true;
|
||||
this.detachLenisScrollListener();
|
||||
this.lenisInstance?.destroy();
|
||||
this.lenisInstance = null;
|
||||
this.detachNativeScrollListener();
|
||||
this.scrollCallbacks = [];
|
||||
this.sweetScroller.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,30 +8,36 @@ export class ThemeManager {
|
||||
public preferredColorSchemeMediaMatch = window.matchMedia('(prefers-color-scheme: light)');
|
||||
|
||||
public themeObservable = new plugins.smartrx.rxjs.ReplaySubject<boolean>(1);
|
||||
private automaticGlobalThemeChangeSubscription: plugins.smartrx.rxjs.Subscription | null = null;
|
||||
private readonly preferredColorSchemeChangeHandler = (eventArg: MediaQueryListEvent) => {
|
||||
this.goBrightBoolean = eventArg.matches;
|
||||
this.updateAllConnectedElements();
|
||||
};
|
||||
|
||||
constructor(domtoolsRefArg: DomTools) {
|
||||
this.domtoolsRef = domtoolsRefArg;
|
||||
|
||||
// lets care
|
||||
this.goBrightBoolean = this.preferredColorSchemeMediaMatch.matches;
|
||||
this.preferredColorSchemeMediaMatch.addEventListener('change', (eventArg) => {
|
||||
this.goBrightBoolean = eventArg.matches;
|
||||
this.updateAllConnectedElements();
|
||||
});
|
||||
this.preferredColorSchemeMediaMatch.addEventListener('change', this.preferredColorSchemeChangeHandler);
|
||||
this.updateAllConnectedElements();
|
||||
}
|
||||
|
||||
public async enableAutomaticGlobalThemeChange() {
|
||||
if (document.body && document.body.style) {
|
||||
this.themeObservable.subscribe({
|
||||
await this.domtoolsRef.domReady.promise;
|
||||
if (!this.automaticGlobalThemeChangeSubscription) {
|
||||
this.automaticGlobalThemeChangeSubscription = this.themeObservable.subscribe({
|
||||
next: (goBright) => {
|
||||
document.body.style.background = goBright ? '#fff' : '#000';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async updateAllConnectedElements() {
|
||||
private updateAllConnectedElements() {
|
||||
if (this.domtoolsRef.disposed) {
|
||||
return;
|
||||
}
|
||||
this.themeObservable.next(this.goBrightBoolean);
|
||||
}
|
||||
|
||||
@@ -58,4 +64,11 @@ export class ThemeManager {
|
||||
this.goBrightBoolean = !this.goBrightBoolean;
|
||||
this.updateAllConnectedElements();
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.preferredColorSchemeMediaMatch.removeEventListener('change', this.preferredColorSchemeChangeHandler);
|
||||
this.automaticGlobalThemeChangeSubscription?.unsubscribe();
|
||||
this.automaticGlobalThemeChangeSubscription = null;
|
||||
this.themeObservable.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as plugins from './domtools.plugins.js';
|
||||
import { DomTools, type IDomToolsContructorOptions } from './domtools.classes.domtools.js';
|
||||
import { scrollBarStyles, globalBaseStyles } from './domtools.css.basestyles.js';
|
||||
|
||||
@@ -51,9 +50,13 @@ export const setup = async (
|
||||
// not used right now
|
||||
}
|
||||
|
||||
domTools.runOnce('elementBasicSetup', async () => {
|
||||
await domTools.runOnce('elementBasicSetup', async () => {
|
||||
// bodyStyles
|
||||
domTools.setGlobalStyles(globalBaseStyles);
|
||||
await domTools.setGlobalStyles(globalBaseStyles);
|
||||
});
|
||||
if (domTools.globalStylesReady.status === 'pending') {
|
||||
domTools.globalStylesReady.resolve();
|
||||
}
|
||||
await domTools.globalStylesReady.promise;
|
||||
return domTools;
|
||||
};
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
import * as smartmarkdown from '@push.rocks/smartmarkdown';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import SweetScroll from 'sweet-scroll';
|
||||
import * as smartstate from '@push.rocks/smartstate';
|
||||
import * as smartrouter from '@push.rocks/smartrouter';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartstring from '@push.rocks/smartstring';
|
||||
import * as smarturl from '@push.rocks/smarturl';
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
|
||||
export {
|
||||
smartdelay,
|
||||
smartmarkdown,
|
||||
smartpromise,
|
||||
SweetScroll,
|
||||
smartstate,
|
||||
export {
|
||||
smartdelay,
|
||||
smartmarkdown,
|
||||
smartpromise,
|
||||
SweetScroll,
|
||||
smartstate,
|
||||
smartrouter,
|
||||
smartrx,
|
||||
smartstring,
|
||||
smarturl,
|
||||
typedrequest
|
||||
};
|
||||
smartrx,
|
||||
smartstring,
|
||||
smarturl,
|
||||
typedrequest,
|
||||
} from './domtools.plugins.js';
|
||||
|
||||
Reference in New Issue
Block a user