fix(domtools): stabilize DomTools lifecycle, cleanup, and singleton behavior

This commit is contained in:
2026-04-24 04:54:40 +00:00
parent 6c5271015d
commit 06f0ea4f92
10 changed files with 781 additions and 163 deletions
+11
View File
@@ -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
+26
View File
@@ -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
View File
@@ -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();
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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' };
}
}
}
}
+80 -29
View File
@@ -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();
}
}
+21 -8
View File
@@ -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();
}
}
+6 -3
View File
@@ -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;
};
+11 -22
View File
@@ -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';