diff --git a/changelog.md b/changelog.md index 338c6da..15b43d8 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/readme.md b/readme.md index 794d2ab..250befd 100644 --- a/readme.md +++ b/readme.md @@ -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`. +`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: diff --git a/test/test.chromium.ts b/test/test.chromium.ts index 4034d6f..474039b 100644 --- a/test/test.chromium.ts +++ b/test/test.chromium.ts @@ -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((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(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index bf24822..427c387 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/domtools.classes.domtools.ts b/ts/domtools.classes.domtools.ts index 880c411..0e40f28 100644 --- a/ts/domtools.classes.domtools.ts +++ b/ts/domtools.classes.domtools.ts @@ -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 { - // 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; + 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>(); + + private async initialize() { + this.tryMarkDomReady(); + if (this.domReady.status === 'pending') { + document.addEventListener('readystatechange', this.readyStateChangedFunc); + } + if (this.domToolsReady.status === 'pending') { + this.domToolsReady.resolve(); + } + } + + private tryMarkDomReady() { + if (this.disposed || this.domReady.status !== 'pending') { + return; + } + + if (document.readyState !== 'interactive' && document.readyState !== 'complete') { + return; + } + + if (!document.head || !document.body) { + return; + } + + this.elements.headElement = document.head; + this.elements.bodyElement = document.body; + if (!this.keyboard) { + this.keyboard = new Keyboard(document.body); + } + document.removeEventListener('readystatechange', this.readyStateChangedFunc); + this.domReady.resolve(); + } + + private trackManagedDomNode(elementArg: T): T { + this.managedDomNodes.push(elementArg); + return elementArg; + } + + private getHeadOrBodyElement() { + const targetElement = this.elements.headElement || document.head || this.elements.bodyElement || document.body; + if (!targetElement) { + throw new Error('DomTools could not find a DOM target element to attach resources to'); + } + return targetElement; + } /** * run a function once and always get the Promise of the first execution @@ -135,34 +175,14 @@ export class DomTools { * @param funcArg the actual func arg to run */ public async runOnce(identifierArg: string, funcArg: () => Promise) { - 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 | 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(); + 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(); + 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; + } + } } diff --git a/ts/domtools.classes.keyboard.ts b/ts/domtools.classes.keyboard.ts index 76ea29f..7295e9e 100644 --- a/ts/domtools.classes.keyboard.ts +++ b/ts/domtools.classes.keyboard.ts @@ -129,8 +129,15 @@ export enum Key { } export class Keyboard { - private mapCombosToHandlers = new Map>(); + private mapCombosToHandlers = new Map< + string, + { + keys: Key[]; + subjects: plugins.smartrx.rxjs.Subject[]; + } + >(); private pressedKeys = new Set(); + 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, subjectArg: plugins.smartrx.rxjs.Subject ) { - 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' }; + } } } } diff --git a/ts/domtools.classes.scroller.ts b/ts/domtools.classes.scroller.ts index 19866c8..bb750bf 100644 --- a/ts/domtools.classes.scroller.ts +++ b/ts/domtools.classes.scroller.ts @@ -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(); - 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; } } -} \ No newline at end of file + + public dispose() { + if (this.disposed) { + return; + } + + this.disposed = true; + this.detachLenisScrollListener(); + this.lenisInstance?.destroy(); + this.lenisInstance = null; + this.detachNativeScrollListener(); + this.scrollCallbacks = []; + this.sweetScroller.destroy(); + } +} diff --git a/ts/domtools.classes.thememanager.ts b/ts/domtools.classes.thememanager.ts index 6bb13f0..fbd4ec3 100644 --- a/ts/domtools.classes.thememanager.ts +++ b/ts/domtools.classes.thememanager.ts @@ -8,30 +8,36 @@ export class ThemeManager { public preferredColorSchemeMediaMatch = window.matchMedia('(prefers-color-scheme: light)'); public themeObservable = new plugins.smartrx.rxjs.ReplaySubject(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(); + } } diff --git a/ts/domtools.elementbasic.ts b/ts/domtools.elementbasic.ts index 954dfee..a791c1f 100644 --- a/ts/domtools.elementbasic.ts +++ b/ts/domtools.elementbasic.ts @@ -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; }; diff --git a/ts/domtools.pluginexports.ts b/ts/domtools.pluginexports.ts index d950e2c..cab02c8 100644 --- a/ts/domtools.pluginexports.ts +++ b/ts/domtools.pluginexports.ts @@ -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';