fix(domtools): stabilize DomTools lifecycle, cleanup, and singleton behavior
This commit is contained in:
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-04-23 - 2.5.5 - fix(build)
|
||||||
align test imports with tstest tapbundle and simplify the build script
|
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`
|
- `TypedRequest` re-exported from `@api.global/typedrequest`
|
||||||
- `plugins` for direct access to the package ecosystem used internally
|
- `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
|
## Quick Start
|
||||||
|
|
||||||
```ts
|
```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.
|
`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
|
## DomTools Lifecycle
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -50,6 +54,8 @@ await domtools.domToolsReady.promise;
|
|||||||
await domtools.domReady.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:
|
Main instance properties:
|
||||||
|
|
||||||
- `elements.headElement` and `elements.bodyElement`
|
- `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.
|
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
|
## DOM, CSS, and External Resources
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -122,6 +144,8 @@ await themeManager.enableAutomaticGlobalThemeChange();
|
|||||||
|
|
||||||
The theme manager starts from `prefers-color-scheme` and publishes updates through an RxJS `ReplaySubject<boolean>`.
|
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
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
The keyboard helper is created after `document.body` exists, so wait for `domReady` before using it.
|
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.
|
`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
|
## State and One-Time Work
|
||||||
|
|
||||||
`domToolsStatePart` starts with this shape:
|
`domToolsStatePart` starts with this shape:
|
||||||
|
|||||||
+114
-3
@@ -1,9 +1,120 @@
|
|||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as domtools from '../ts/index.js';
|
import * as domtools from '../ts/index.js';
|
||||||
|
|
||||||
tap.test('first test', async () => {
|
const cleanupGlobalDomTools = () => {
|
||||||
const domtoolsInstance = await domtools.DomTools.setupDomTools();
|
try {
|
||||||
expect(domtoolsInstance).toBeInstanceOf(domtools.DomTools);
|
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();
|
export default tap.start();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@design.estate/dees-domtools',
|
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.'
|
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 { ThemeManager } from './domtools.classes.thememanager.js';
|
||||||
import { Keyboard } from './domtools.classes.keyboard.js';
|
import { Keyboard } from './domtools.classes.keyboard.js';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var deesDomTools: DomTools | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IDomToolsState {
|
export interface IDomToolsState {
|
||||||
virtualViewport: TViewport;
|
virtualViewport: TViewport;
|
||||||
jwt: string;
|
jwt: string;
|
||||||
@@ -24,59 +28,43 @@ export class DomTools {
|
|||||||
* setups domtools
|
* setups domtools
|
||||||
*/
|
*/
|
||||||
public static async setupDomTools(optionsArg: IDomToolsContructorOptions = {}): Promise<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) {
|
||||||
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
|
|
||||||
const domToolsInstance = new DomTools(optionsArg);
|
const domToolsInstance = new DomTools(optionsArg);
|
||||||
|
await domToolsInstance.initializationPromise;
|
||||||
return domToolsInstance;
|
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.
|
* if you can, use the static asysnc .setupDomTools() function instead since it is safer to use.
|
||||||
*/
|
*/
|
||||||
public static getGlobalDomToolsSync(): DomTools {
|
public static getGlobalDomToolsSync(): DomTools {
|
||||||
const globalDomTools: DomTools = globalThis.deesDomTools;
|
const globalDomTools = globalThis.deesDomTools;
|
||||||
if (!globalDomTools) {
|
if (!globalDomTools || globalDomTools.disposed) {
|
||||||
throw new Error('You tried to access domtools synchronously too early');
|
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 scroller = new Scroller(this);
|
||||||
public themeManager = new ThemeManager(this);
|
public themeManager = new ThemeManager(this);
|
||||||
public keyboard: Keyboard | null = null; // Initialized after DOM ready to avoid accessing document.body before it exists
|
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 domToolsReady = plugins.smartpromise.defer();
|
||||||
public domReady = plugins.smartpromise.defer();
|
public domReady = plugins.smartpromise.defer();
|
||||||
public globalStylesReady = 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();
|
constructor(optionsArg: IDomToolsContructorOptions) {
|
||||||
private runOnceResultMap = new plugins.lik.FastMap();
|
this.initializationPromise = this.initialize();
|
||||||
private runOnceErrorMap = new plugins.lik.FastMap();
|
}
|
||||||
|
|
||||||
|
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
|
* 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
|
* @param funcArg the actual func arg to run
|
||||||
*/
|
*/
|
||||||
public async runOnce<T>(identifierArg: string, funcArg: () => Promise<T>) {
|
public async runOnce<T>(identifierArg: string, funcArg: () => Promise<T>) {
|
||||||
const runningId = `${identifierArg}+runningCheck`;
|
let runOncePromise = this.runOncePromiseMap.get(identifierArg) as Promise<T> | undefined;
|
||||||
if (!this.runOnceTrackerStringMap.checkString(identifierArg)) {
|
if (!runOncePromise) {
|
||||||
this.runOnceTrackerStringMap.addString(identifierArg);
|
runOncePromise = Promise.resolve().then(async () => {
|
||||||
this.runOnceTrackerStringMap.addString(runningId);
|
return await funcArg();
|
||||||
try {
|
});
|
||||||
const result = await funcArg();
|
this.runOncePromiseMap.set(identifierArg, runOncePromise);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
return await 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);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// setStuff
|
// setStuff
|
||||||
@@ -172,10 +192,10 @@ export class DomTools {
|
|||||||
*/
|
*/
|
||||||
public async setGlobalStyles(stylesText: string) {
|
public async setGlobalStyles(stylesText: string) {
|
||||||
await this.domReady.promise;
|
await this.domReady.promise;
|
||||||
const styleElement = document.createElement('style');
|
const styleElement = this.trackManagedDomNode(document.createElement('style'));
|
||||||
styleElement.type = 'text/css';
|
styleElement.type = 'text/css';
|
||||||
styleElement.appendChild(document.createTextNode(stylesText));
|
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) {
|
public async setExternalScript(scriptLinkArg: string) {
|
||||||
await this.domReady.promise;
|
await this.domReady.promise;
|
||||||
const done = plugins.smartpromise.defer();
|
const done = plugins.smartpromise.defer<void>();
|
||||||
const script = document.createElement('script');
|
const script = this.trackManagedDomNode(document.createElement('script'));
|
||||||
script.src = scriptLinkArg;
|
script.src = scriptLinkArg;
|
||||||
script.addEventListener('load', function () {
|
script.addEventListener('load', () => {
|
||||||
done.resolve();
|
done.resolve();
|
||||||
});
|
});
|
||||||
const parentNode = document.head || document.body;
|
script.addEventListener('error', () => {
|
||||||
parentNode.append(script);
|
done.reject(new Error(`Failed to load external script: ${scriptLinkArg}`));
|
||||||
|
});
|
||||||
|
this.getHeadOrBodyElement().append(script);
|
||||||
await done.promise;
|
await done.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,11 +222,20 @@ export class DomTools {
|
|||||||
* @param cssLinkArg a url to an external stylesheet
|
* @param cssLinkArg a url to an external stylesheet
|
||||||
*/
|
*/
|
||||||
public async setExternalCss(cssLinkArg: string) {
|
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.rel = 'stylesheet';
|
||||||
cssTag.crossOrigin = 'anonymous';
|
cssTag.crossOrigin = 'anonymous';
|
||||||
cssTag.href = cssLinkArg;
|
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.setup(optionsArg);
|
||||||
await this.websetup.readyPromise;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+373
-14
@@ -129,8 +129,15 @@ export enum Key {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Keyboard {
|
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 pressedKeys = new Set<Key>();
|
||||||
|
private listening = false;
|
||||||
|
|
||||||
constructor(private domNode: Element | Document) {
|
constructor(private domNode: Element | Document) {
|
||||||
this.startListening();
|
this.startListening();
|
||||||
@@ -145,46 +152,74 @@ export class Keyboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public triggerKeyPress(keysArg: Key[]) {
|
public triggerKeyPress(keysArg: Key[]) {
|
||||||
for (const key of keysArg) {
|
const normalizedKeys = this.normalizeKeys(keysArg);
|
||||||
|
for (const key of normalizedKeys) {
|
||||||
this.pressedKeys.add(key);
|
this.pressedKeys.add(key);
|
||||||
}
|
}
|
||||||
this.checkMatchingKeyboardSubjects();
|
this.checkMatchingKeyboardSubjects(this.createKeyboardEvent(normalizedKeys));
|
||||||
for (const key of keysArg) {
|
for (const key of normalizedKeys) {
|
||||||
this.pressedKeys.delete(key);
|
this.pressedKeys.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public startListening() {
|
public startListening() {
|
||||||
|
if (this.listening) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.domNode.addEventListener('keydown', this.handleKeyDown as EventListener);
|
this.domNode.addEventListener('keydown', this.handleKeyDown as EventListener);
|
||||||
this.domNode.addEventListener('keyup', this.handleKeyUp as EventListener);
|
this.domNode.addEventListener('keyup', this.handleKeyUp as EventListener);
|
||||||
|
this.listening = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public stopListening() {
|
public stopListening() {
|
||||||
|
if (!this.listening) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.domNode.removeEventListener('keydown', this.handleKeyDown as EventListener);
|
this.domNode.removeEventListener('keydown', this.handleKeyDown as EventListener);
|
||||||
this.domNode.removeEventListener('keyup', this.handleKeyUp as EventListener);
|
this.domNode.removeEventListener('keyup', this.handleKeyUp as EventListener);
|
||||||
|
this.listening = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public clear() {
|
public clear() {
|
||||||
this.stopListening();
|
this.stopListening();
|
||||||
|
for (const comboEntry of this.mapCombosToHandlers.values()) {
|
||||||
|
for (const subject of comboEntry.subjects) {
|
||||||
|
subject.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
this.mapCombosToHandlers.clear();
|
this.mapCombosToHandlers.clear();
|
||||||
this.pressedKeys.clear();
|
this.pressedKeys.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public dispose() {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
private handleKeyDown = (event: KeyboardEvent) => {
|
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);
|
this.checkMatchingKeyboardSubjects(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
private checkMatchingKeyboardSubjects(payloadArg?) {
|
private checkMatchingKeyboardSubjects(payloadArg: KeyboardEvent) {
|
||||||
this.mapCombosToHandlers.forEach((subjectArg, keysArg) => {
|
this.mapCombosToHandlers.forEach((comboEntry) => {
|
||||||
if (this.areAllKeysPressed(keysArg)) {
|
if (this.areAllKeysPressed(comboEntry.keys)) {
|
||||||
|
for (const subjectArg of comboEntry.subjects) {
|
||||||
subjectArg.next(payloadArg);
|
subjectArg.next(payloadArg);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleKeyUp = (event: KeyboardEvent) => {
|
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[]) {
|
private areAllKeysPressed(keysArg: Key[]) {
|
||||||
@@ -203,11 +238,335 @@ export class Keyboard {
|
|||||||
keysArg: Array<Key>,
|
keysArg: Array<Key>,
|
||||||
subjectArg: plugins.smartrx.rxjs.Subject<KeyboardEvent>
|
subjectArg: plugins.smartrx.rxjs.Subject<KeyboardEvent>
|
||||||
) {
|
) {
|
||||||
if (!this.mapCombosToHandlers.has(keysArg)) {
|
const normalizedKeys = this.normalizeKeys(keysArg);
|
||||||
this.mapCombosToHandlers.set(keysArg, subjectArg);
|
const comboKey = normalizedKeys.join('+');
|
||||||
} else {
|
const existingEntry = this.mapCombosToHandlers.get(comboKey);
|
||||||
const subject = this.mapCombosToHandlers.get(keysArg);
|
if (!existingEntry) {
|
||||||
return subject;
|
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 {
|
export class Scroller {
|
||||||
public domtoolsInstance: DomTools;
|
public domtoolsInstance: DomTools;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
// Array to store scroll callback functions.
|
// Array to store scroll callback functions.
|
||||||
private scrollCallbacks: Array<() => void> = [];
|
private scrollCallbacks: Array<() => void> = [];
|
||||||
|
|
||||||
// Lenis instance (if activated) or null.
|
// Lenis instance (if activated) or null.
|
||||||
private lenisInstance: plugins.Lenis | null = null;
|
private lenisInstance: plugins.Lenis | null = null;
|
||||||
|
private lenisScrollUnsubscribe: (() => void) | null = null;
|
||||||
|
private nativeScrollListenerAttached = false;
|
||||||
|
|
||||||
// Bound handlers to allow removal from event listeners.
|
// Bound handlers to allow removal from event listeners.
|
||||||
private handleNativeScroll = (event: Event): void => {
|
private handleNativeScroll = (_event: Event): void => {
|
||||||
this.executeScrollCallbacks();
|
this.executeScrollCallbacks();
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleLenisScroll = (info: any): void => {
|
private handleLenisScroll = (_info: plugins.Lenis): void => {
|
||||||
this.executeScrollCallbacks();
|
this.executeScrollCallbacks();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,23 +47,40 @@ export class Scroller {
|
|||||||
* Detects whether native smooth scrolling is enabled.
|
* Detects whether native smooth scrolling is enabled.
|
||||||
*/
|
*/
|
||||||
public async detectNativeSmoothScroll() {
|
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 done = plugins.smartpromise.defer<boolean>();
|
||||||
const sampleSize = 100;
|
const sampleSize = 12;
|
||||||
const acceptableDeltaDifference = 3;
|
const acceptableDeltaDifference = 3;
|
||||||
const minimumSmoothRatio = 0.75;
|
const minimumSmoothRatio = 0.75;
|
||||||
|
const timeoutInMs = 1200;
|
||||||
|
|
||||||
const eventDeltas: number[] = [];
|
const eventDeltas: number[] = [];
|
||||||
|
|
||||||
|
const finalize = (result: boolean) => {
|
||||||
|
window.removeEventListener('wheel', onWheel);
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
done.resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
function onWheel(event: WheelEvent) {
|
function onWheel(event: WheelEvent) {
|
||||||
eventDeltas.push(event.deltaY);
|
eventDeltas.push(event.deltaY);
|
||||||
|
|
||||||
if (eventDeltas.length >= sampleSize) {
|
if (eventDeltas.length >= sampleSize) {
|
||||||
window.removeEventListener('wheel', onWheel);
|
|
||||||
analyzeEvents();
|
analyzeEvents();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function analyzeEvents() {
|
function analyzeEvents() {
|
||||||
|
if (eventDeltas.length < 2) {
|
||||||
|
finalize(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const totalDiffs = eventDeltas.length - 1;
|
const totalDiffs = eventDeltas.length - 1;
|
||||||
let smallDiffCount = 0;
|
let smallDiffCount = 0;
|
||||||
|
|
||||||
@@ -72,16 +92,14 @@ export class Scroller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const smoothRatio = smallDiffCount / totalDiffs;
|
const smoothRatio = smallDiffCount / totalDiffs;
|
||||||
if (smoothRatio >= minimumSmoothRatio) {
|
finalize(smoothRatio >= minimumSmoothRatio);
|
||||||
console.log('Smooth scrolling detected.');
|
|
||||||
done.resolve(true);
|
|
||||||
} else {
|
|
||||||
console.log('Smooth scrolling NOT detected.');
|
|
||||||
done.resolve(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('wheel', onWheel);
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
analyzeEvents();
|
||||||
|
}, timeoutInMs);
|
||||||
|
|
||||||
|
window.addEventListener('wheel', onWheel, { passive: true });
|
||||||
return done.promise;
|
return done.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +109,14 @@ export class Scroller {
|
|||||||
* Lenis will be destroyed immediately.
|
* Lenis will be destroyed immediately.
|
||||||
*/
|
*/
|
||||||
public async enableLenisScroll(optionsArg?: { disableOnNativeSmoothScroll?: boolean }) {
|
public async enableLenisScroll(optionsArg?: { disableOnNativeSmoothScroll?: boolean }) {
|
||||||
|
if (this.disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.lenisInstance) {
|
||||||
|
this.disableLenisScroll();
|
||||||
|
}
|
||||||
|
|
||||||
const lenis = new plugins.Lenis({
|
const lenis = new plugins.Lenis({
|
||||||
autoRaf: true,
|
autoRaf: true,
|
||||||
});
|
});
|
||||||
@@ -107,24 +133,28 @@ export class Scroller {
|
|||||||
// Switch from native scroll listener to Lenis scroll listener.
|
// Switch from native scroll listener to Lenis scroll listener.
|
||||||
this.detachNativeScrollListener();
|
this.detachNativeScrollListener();
|
||||||
this.attachLenisScrollListener();
|
this.attachLenisScrollListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
public disableLenisScroll() {
|
||||||
|
if (!this.lenisInstance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 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.detachLenisScrollListener();
|
||||||
this.attachNativeScrollListener();
|
this.lenisInstance.destroy();
|
||||||
this.lenisInstance = null;
|
this.lenisInstance = null;
|
||||||
};
|
this.attachNativeScrollListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a callback to be executed on scroll.
|
* Registers a callback to be executed on scroll.
|
||||||
* @param callback A function to execute on each scroll event.
|
* @param callback A function to execute on each scroll event.
|
||||||
*/
|
*/
|
||||||
public onScroll(callback: () => void): void {
|
public onScroll(callback: () => void): () => void {
|
||||||
this.scrollCallbacks.push(callback);
|
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.
|
* Attaches the native scroll event listener.
|
||||||
*/
|
*/
|
||||||
private attachNativeScrollListener(): void {
|
private attachNativeScrollListener(): void {
|
||||||
|
if (this.nativeScrollListenerAttached || this.disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.addEventListener('scroll', this.handleNativeScroll);
|
window.addEventListener('scroll', this.handleNativeScroll);
|
||||||
|
this.nativeScrollListenerAttached = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detaches the native scroll event listener.
|
* Detaches the native scroll event listener.
|
||||||
*/
|
*/
|
||||||
private detachNativeScrollListener(): void {
|
private detachNativeScrollListener(): void {
|
||||||
|
if (!this.nativeScrollListenerAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.removeEventListener('scroll', this.handleNativeScroll);
|
window.removeEventListener('scroll', this.handleNativeScroll);
|
||||||
|
this.nativeScrollListenerAttached = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attaches the Lenis scroll event listener.
|
* Attaches the Lenis scroll event listener.
|
||||||
*/
|
*/
|
||||||
private attachLenisScrollListener(): void {
|
private attachLenisScrollListener(): void {
|
||||||
if (this.lenisInstance) {
|
if (this.lenisInstance && !this.lenisScrollUnsubscribe) {
|
||||||
// Assuming that Lenis exposes an `on` method to listen to scroll events.
|
this.lenisScrollUnsubscribe = this.lenisInstance.on('scroll', this.handleLenisScroll);
|
||||||
this.lenisInstance.on('scroll', this.handleLenisScroll);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,9 +206,23 @@ export class Scroller {
|
|||||||
* Detaches the Lenis scroll event listener.
|
* Detaches the Lenis scroll event listener.
|
||||||
*/
|
*/
|
||||||
private detachLenisScrollListener(): void {
|
private detachLenisScrollListener(): void {
|
||||||
if (this.lenisInstance) {
|
if (this.lenisScrollUnsubscribe) {
|
||||||
// Assuming that Lenis exposes an `off` method to remove scroll event listeners.
|
this.lenisScrollUnsubscribe();
|
||||||
this.lenisInstance.off('scroll', this.handleLenisScroll);
|
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 preferredColorSchemeMediaMatch = window.matchMedia('(prefers-color-scheme: light)');
|
||||||
|
|
||||||
public themeObservable = new plugins.smartrx.rxjs.ReplaySubject<boolean>(1);
|
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) {
|
constructor(domtoolsRefArg: DomTools) {
|
||||||
this.domtoolsRef = domtoolsRefArg;
|
this.domtoolsRef = domtoolsRefArg;
|
||||||
|
|
||||||
// lets care
|
// lets care
|
||||||
this.goBrightBoolean = this.preferredColorSchemeMediaMatch.matches;
|
this.goBrightBoolean = this.preferredColorSchemeMediaMatch.matches;
|
||||||
this.preferredColorSchemeMediaMatch.addEventListener('change', (eventArg) => {
|
this.preferredColorSchemeMediaMatch.addEventListener('change', this.preferredColorSchemeChangeHandler);
|
||||||
this.goBrightBoolean = eventArg.matches;
|
|
||||||
this.updateAllConnectedElements();
|
|
||||||
});
|
|
||||||
this.updateAllConnectedElements();
|
this.updateAllConnectedElements();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async enableAutomaticGlobalThemeChange() {
|
public async enableAutomaticGlobalThemeChange() {
|
||||||
if (document.body && document.body.style) {
|
await this.domtoolsRef.domReady.promise;
|
||||||
this.themeObservable.subscribe({
|
if (!this.automaticGlobalThemeChangeSubscription) {
|
||||||
|
this.automaticGlobalThemeChangeSubscription = this.themeObservable.subscribe({
|
||||||
next: (goBright) => {
|
next: (goBright) => {
|
||||||
document.body.style.background = goBright ? '#fff' : '#000';
|
document.body.style.background = goBright ? '#fff' : '#000';
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateAllConnectedElements() {
|
private updateAllConnectedElements() {
|
||||||
|
if (this.domtoolsRef.disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.themeObservable.next(this.goBrightBoolean);
|
this.themeObservable.next(this.goBrightBoolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,4 +64,11 @@ export class ThemeManager {
|
|||||||
this.goBrightBoolean = !this.goBrightBoolean;
|
this.goBrightBoolean = !this.goBrightBoolean;
|
||||||
this.updateAllConnectedElements();
|
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 { DomTools, type IDomToolsContructorOptions } from './domtools.classes.domtools.js';
|
||||||
import { scrollBarStyles, globalBaseStyles } from './domtools.css.basestyles.js';
|
import { scrollBarStyles, globalBaseStyles } from './domtools.css.basestyles.js';
|
||||||
|
|
||||||
@@ -51,9 +50,13 @@ export const setup = async (
|
|||||||
// not used right now
|
// not used right now
|
||||||
}
|
}
|
||||||
|
|
||||||
domTools.runOnce('elementBasicSetup', async () => {
|
await domTools.runOnce('elementBasicSetup', async () => {
|
||||||
// bodyStyles
|
// bodyStyles
|
||||||
domTools.setGlobalStyles(globalBaseStyles);
|
await domTools.setGlobalStyles(globalBaseStyles);
|
||||||
});
|
});
|
||||||
|
if (domTools.globalStylesReady.status === 'pending') {
|
||||||
|
domTools.globalStylesReady.resolve();
|
||||||
|
}
|
||||||
|
await domTools.globalStylesReady.promise;
|
||||||
return domTools;
|
return domTools;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,3 @@
|
|||||||
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 {
|
export {
|
||||||
smartdelay,
|
smartdelay,
|
||||||
smartmarkdown,
|
smartmarkdown,
|
||||||
@@ -19,5 +8,5 @@ export {
|
|||||||
smartrx,
|
smartrx,
|
||||||
smartstring,
|
smartstring,
|
||||||
smarturl,
|
smarturl,
|
||||||
typedrequest
|
typedrequest,
|
||||||
};
|
} from './domtools.plugins.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user