UNPKG

@ogs-gmbh/ngx-utils

Version:

A lightweight collection of utility functions and helpers for Angular applications

531 lines (520 loc) 20.5 kB
import * as i0 from '@angular/core'; import { Injectable, inject, Renderer2, ElementRef, input, computed, Directive, EventEmitter, Output } from '@angular/core'; import { toObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { fromEvent, merge, throttleTime, debounceTime, filter, animationFrameScheduler } from 'rxjs'; /** * Static timestamp helper functions */ /* eslint-disable-next-line @tseslint/no-extraneous-class */ class TimestampUtil { /** * Validate timestamp by its value against current timestamp * @param {number} timestamp - Timestamp, that'll be checked * @return {boolean} - Returns true if Timestamp is greater than current Timestamp, otherwise false */ static isValidAgainstNow(timestamp) { return timestamp > Date.now(); } } /** * Static storage helper methods * */ /* eslint-disable-next-line @tseslint/no-extraneous-class */ class StorageUtil { /** * Generate random UUID * @return {string} - Random UUID */ static generateRandomUUID() { return crypto.randomUUID(); } } var CustomValidators; (function (CustomValidators) { /* eslint-disable-next-line @tseslint/no-shadow */ CustomValidators.length = (requiredLength) => (control) => (control.value.length === requiredLength ? { invalid: true } : null); })(CustomValidators || (CustomValidators = {})); var KeyboardKeys; (function (KeyboardKeys) { KeyboardKeys.BACKSPACE = "Backspace"; KeyboardKeys.ENTER = "Enter"; KeyboardKeys.ESCAPE = "Escape"; KeyboardKeys.COMMA = ","; KeyboardKeys.PERIOD = "."; KeyboardKeys.COLON = ":"; KeyboardKeys.HASH = "#"; KeyboardKeys.SPACE = " "; KeyboardKeys.PLUS = "+"; KeyboardKeys.MINUS = "-"; KeyboardKeys.DIGIT_0 = "0"; KeyboardKeys.DIGIT_1 = "1"; KeyboardKeys.DIGIT_2 = "2"; KeyboardKeys.DIGIT_3 = "3"; KeyboardKeys.DIGIT_4 = "4"; KeyboardKeys.DIGIT_5 = "5"; KeyboardKeys.DIGIT_6 = "6"; KeyboardKeys.DIGIT_7 = "7"; KeyboardKeys.DIGIT_8 = "8"; KeyboardKeys.DIGIT_9 = "9"; KeyboardKeys.LOWER_A = "a"; KeyboardKeys.LOWER_B = "b"; KeyboardKeys.LOWER_C = "c"; KeyboardKeys.LOWER_D = "d"; KeyboardKeys.LOWER_E = "e"; KeyboardKeys.LOWER_F = "f"; KeyboardKeys.LOWER_G = "g"; KeyboardKeys.LOWER_H = "h"; KeyboardKeys.LOWER_I = "i"; KeyboardKeys.LOWER_J = "j"; KeyboardKeys.LOWER_K = "k"; KeyboardKeys.LOWER_L = "l"; KeyboardKeys.LOWER_M = "m"; KeyboardKeys.LOWER_N = "n"; KeyboardKeys.LOWER_O = "o"; KeyboardKeys.LOWER_P = "p"; KeyboardKeys.LOWER_Q = "q"; KeyboardKeys.LOWER_R = "r"; KeyboardKeys.LOWER_S = "s"; KeyboardKeys.LOWER_T = "t"; KeyboardKeys.LOWER_U = "u"; KeyboardKeys.LOWER_V = "v"; KeyboardKeys.LOWER_W = "w"; KeyboardKeys.LOWER_X = "x"; KeyboardKeys.LOWER_Y = "y"; KeyboardKeys.LOWER_Z = "z"; KeyboardKeys.UPPER_A = "A"; KeyboardKeys.UPPER_B = "B"; KeyboardKeys.UPPER_C = "C"; KeyboardKeys.UPPER_D = "D"; KeyboardKeys.UPPER_E = "E"; KeyboardKeys.UPPER_F = "F"; KeyboardKeys.UPPER_G = "G"; KeyboardKeys.UPPER_H = "H"; KeyboardKeys.UPPER_I = "I"; KeyboardKeys.UPPER_J = "J"; KeyboardKeys.UPPER_K = "K"; KeyboardKeys.UPPER_L = "L"; KeyboardKeys.UPPER_M = "M"; KeyboardKeys.UPPER_N = "N"; KeyboardKeys.UPPER_O = "O"; KeyboardKeys.UPPER_P = "P"; KeyboardKeys.UPPER_Q = "Q"; KeyboardKeys.UPPER_R = "R"; KeyboardKeys.UPPER_S = "S"; KeyboardKeys.UPPER_T = "T"; KeyboardKeys.UPPER_U = "U"; KeyboardKeys.UPPER_V = "V"; KeyboardKeys.UPPER_W = "W"; KeyboardKeys.UPPER_X = "X"; KeyboardKeys.UPPER_Y = "Y"; KeyboardKeys.UPPER_Z = "Z"; })(KeyboardKeys || (KeyboardKeys = {})); var KeyboardKeyArrays; (function (KeyboardKeyArrays) { KeyboardKeyArrays.DIGITS = [KeyboardKeys.DIGIT_0, KeyboardKeys.DIGIT_1, KeyboardKeys.DIGIT_2, KeyboardKeys.DIGIT_3, KeyboardKeys.DIGIT_4, KeyboardKeys.DIGIT_5, KeyboardKeys.DIGIT_6, KeyboardKeys.DIGIT_7, KeyboardKeys.DIGIT_8, KeyboardKeys.DIGIT_9]; KeyboardKeyArrays.UPPER_LETTERS = [KeyboardKeys.UPPER_A, KeyboardKeys.UPPER_B, KeyboardKeys.UPPER_C, KeyboardKeys.UPPER_D, KeyboardKeys.UPPER_E, KeyboardKeys.UPPER_F, KeyboardKeys.UPPER_G, KeyboardKeys.UPPER_H, KeyboardKeys.UPPER_I, KeyboardKeys.UPPER_J, KeyboardKeys.UPPER_K, KeyboardKeys.UPPER_L, KeyboardKeys.UPPER_M, KeyboardKeys.UPPER_N, KeyboardKeys.UPPER_O, KeyboardKeys.UPPER_P, KeyboardKeys.UPPER_Q, KeyboardKeys.UPPER_R, KeyboardKeys.UPPER_S, KeyboardKeys.UPPER_T, KeyboardKeys.UPPER_U, KeyboardKeys.UPPER_V, KeyboardKeys.UPPER_W, KeyboardKeys.UPPER_X, KeyboardKeys.UPPER_Y, KeyboardKeys.UPPER_Z]; KeyboardKeyArrays.LOWER_LETTERS = [KeyboardKeys.LOWER_A, KeyboardKeys.LOWER_B, KeyboardKeys.LOWER_C, KeyboardKeys.LOWER_D, KeyboardKeys.LOWER_E, KeyboardKeys.LOWER_F, KeyboardKeys.LOWER_G, KeyboardKeys.LOWER_H, KeyboardKeys.LOWER_I, KeyboardKeys.LOWER_J, KeyboardKeys.LOWER_K, KeyboardKeys.LOWER_L, KeyboardKeys.LOWER_M, KeyboardKeys.LOWER_N, KeyboardKeys.LOWER_O, KeyboardKeys.LOWER_P, KeyboardKeys.LOWER_Q, KeyboardKeys.LOWER_R, KeyboardKeys.LOWER_S, KeyboardKeys.LOWER_T, KeyboardKeys.LOWER_U, KeyboardKeys.LOWER_V, KeyboardKeys.LOWER_W, KeyboardKeys.LOWER_X, KeyboardKeys.LOWER_Y, KeyboardKeys.LOWER_Z]; KeyboardKeyArrays.LETTERS = [...KeyboardKeyArrays.UPPER_LETTERS, ...KeyboardKeyArrays.LOWER_LETTERS]; })(KeyboardKeyArrays || (KeyboardKeyArrays = {})); /** * Builds a CSS font shorthand string from a computed style declaration. * * @param elementStyle - The computed CSS style of an element, containing font properties. * @param options.isFontKey - If true, includes letter-spacing in the resulting string for use as a cache key. * @returns A font shorthand string suitable for CanvasRenderingContext2D.font or as a unique font key. */ function getFontString(elementStyle, options = undefined) { let fontString = `${elementStyle.fontStyle} ${elementStyle.fontVariant} ${elementStyle.fontWeight} ${elementStyle.fontSize}/${elementStyle.lineHeight} ${elementStyle.fontFamily}`; if (options?.isFontKey) { fontString += elementStyle.letterSpacing; fontString.trim(); } return fontString; } class CanvasMeasurerService { _canvas; get canvas() { if (this._canvas) return this._canvas; this._canvas = document.createElement("canvas"); return this._canvas; } _canvasContext = this.canvas.getContext("2d"); // Map of maps where each child-map represents a font and each child-map-entry is a string with the measured width _stringWidthCache = new Map(); /** * Measures text width for a given element style and string, * caching results per font configuration. * * @param style - Computed CSS style of the element (font info). * @param text - The string to measure. * @returns The width in pixels, or undefined if measurement failed. */ getRenderedStringWidth(elementStyle, stringToMeasure) { if (!elementStyle) return undefined; const fontString = getFontString(elementStyle, { isFontKey: true }); let fontMap = this._stringWidthCache.get(fontString); if (!fontMap) { fontMap = new Map(); this._stringWidthCache.set(fontString, fontMap); } let width = fontMap.get(stringToMeasure); if (width) return width; if (this._canvasContext) { this.setCanvasFont(elementStyle); width = this._canvasContext.measureText(stringToMeasure).width; fontMap.set(stringToMeasure, width); return width; } return undefined; } setCanvasFont(elementStyle) { if (this._canvasContext?.font) { const fontString = getFontString(elementStyle); if (this._canvasContext.font === fontString && this._canvasContext.letterSpacing === elementStyle.letterSpacing) return; this._canvasContext.font = fontString; this._canvasContext.letterSpacing = elementStyle.letterSpacing; } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: CanvasMeasurerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: CanvasMeasurerService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: CanvasMeasurerService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /* eslint-disable @tseslint/no-non-null-assertion */ /** * Rounds a number up only if its fractional part is at or above the given threshold. * * @param value - The value to round. * @param threshold - The fractional cutoff (0–1) above which to round up. * @returns The rounded integer. */ function roundWithThreshold(value, threshold) { const integerPart = Math.floor(value); const fraction = value - integerPart; return fraction >= threshold ? Math.ceil(value) : integerPart; } /** * Determines whether a word of a given width fits into the remaining line width, * taking into account the ellipsis width on the last line. * * @param wordWidth - Pixel width of the current word. * @param remainingWidth - Remaining pixel width in the current line. * @param currentLine - 1-based index of the current line. * @param maxLines - Total number of allowed lines. * @param ellipsisWidth - Pixel width of the ellipsis string. * @returns True if the word can fit, false otherwise. */ // eslint-disable-next-line @tseslint/max-params function fits(wordWidth, remainingWidth, currentLine, maxLines, ellipsisWidth) { // If last word reached take ellipsis into account if (currentLine === maxLines) return wordWidth + ellipsisWidth <= remainingWidth; return wordWidth <= remainingWidth; } /** * Clamps text to its available space and appends a custom ellipsis. * * @remarks * Limits content automatically to the available size of the parent container. * Takes Line height into account as well as preserving whole words. * Works with changing input in form of signals as well as static input. * * @example **Template (HTML)** * ```html * <p * textClamp * [text]="myText()" * [ellipsis]="'… more'"></p> * ``` * * @example **Component (TypeScript)** * ```ts * @Component({ * standalone: true, * selector: 'app-foo', * imports: [TextClampDirective] * }) * export class ArticleCardComponent { * protected myText: WritableSignal<string> = signal<string>("..."); * * protected onMyEvent(): void { * this.myText.set("something else...") * } * } * ``` */ class TextClampDirective { _isTextInitialized = false; _renderer = inject(Renderer2); _htmlElement = inject((ElementRef)); get _nativeElement() { if (this._htmlElement?.nativeElement) return this._htmlElement.nativeElement; return undefined; } text = input.required(); ellipsis = input("..."); _textChange$ = toObservable(this.text); _canvasMeasureService = inject(CanvasMeasurerService); // eslint-disable-next-line @unicorn/consistent-function-scoping _words = computed(() => { let words = []; if (this.text()) words = this.text().split(" "); return words; }); ngAfterViewInit() { const visibility$ = fromEvent(document, "visibilitychange"); const focus$ = fromEvent(window, "focus"); const resize$ = fromEvent(window, "resize"); // Slower throttle for refocus // eslint-disable-next-line @tseslint/typedef const refocus$ = merge(visibility$, focus$).pipe(throttleTime(500)); // Fast throttle for change in text or resize // eslint-disable-next-line @tseslint/typedef const fastChange$ = merge(resize$, this._textChange$).pipe(debounceTime(10)); merge(refocus$, fastChange$) .pipe(filter(() => !document.hidden), debounceTime(0, animationFrameScheduler)) .subscribe(() => { void this.scheduleEllipsis(); }); // Setting initial text setTimeout(() => { if (this._isTextInitialized) void this.scheduleEllipsis(); }, 0); } setText(text) { if (this._nativeElement) this._renderer.setProperty(this._nativeElement, 'textContent', text); } /** * Schedules the ellipsis calculation by waiting for the font to be fully loaded so it can be measured correctly */ async scheduleEllipsis() { await document.fonts.ready; setTimeout(() => { if (this._nativeElement && this.text()) { // Setting text for the text-container to adjust to its full possible height this.setText(this.text()); if (this._nativeElement.clientHeight === this._nativeElement.scrollHeight) return; requestAnimationFrame(() => { const clampedText = this.getClampedText(); if (clampedText) this.setText(clampedText); }); } }, 0); } /** * Calculates the multiline ellipsis by measuring word widths * against the available space and line count. * * @returns clamped text or undefined if measurement went wrong */ getClampedText() { if (this._nativeElement && this.text()) { if (!this._isTextInitialized) this._isTextInitialized = true; let clampText = ""; const elementStyle = getComputedStyle(this._nativeElement); const lineHeight = Number.parseFloat(elementStyle.lineHeight); const lineCount = roundWithThreshold(this._nativeElement.clientHeight / lineHeight, 0.9); const ellipsisWidth = this._canvasMeasureService.getRenderedStringWidth(elementStyle, ` ${this.ellipsis()} `); if (!ellipsisWidth) return undefined; let usedWordsCount = 0; let linePointer = 1; let remainingLineWidth = this._nativeElement.clientWidth; if (lineCount > 0) { for (let i = 0; i < this._words().length; i++) { // eslint-disable-next-line prefer-template const word = i === 0 ? this._words()[i] : " " + this._words()[i]; const wordWidth = this._canvasMeasureService.getRenderedStringWidth(elementStyle, word); if (!wordWidth) return undefined; // Check if word fits in line if (fits(wordWidth, remainingLineWidth, linePointer, lineCount, ellipsisWidth)) { clampText += word; remainingLineWidth -= wordWidth; usedWordsCount++; // eslint-disable-next-line @stylistic/ts/brace-style } // Check if there is a next line else if (linePointer < lineCount) { linePointer++; remainingLineWidth = this._nativeElement.clientWidth; // Check if word fits in line if (fits(wordWidth, remainingLineWidth, linePointer, lineCount, ellipsisWidth)) { clampText += word; remainingLineWidth -= wordWidth; usedWordsCount++; } else break; } else break; } if (usedWordsCount > 0 && usedWordsCount < this._words().length) clampText += this.ellipsis(); } return clampText; } return undefined; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TextClampDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "18.2.14", type: TextClampDirective, isStandalone: true, selector: "[textClamp]", inputs: { text: { classPropertyName: "text", publicName: "text", isSignal: true, isRequired: true, transformFunction: null }, ellipsis: { classPropertyName: "ellipsis", publicName: "ellipsis", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TextClampDirective, decorators: [{ type: Directive, args: [{ selector: '[textClamp]', standalone: true }] }] }); /** * Throttles click events on an HTML element. * * @remarks * Prevents rapid repeated clicks by allowing only one `(throttleClick)` event * to fire within the configured interval. Works with any clickable element. * * @example **Template (HTML)** * ```html * <button * (throttleClick)="onCounterClick($event)" * [throttleTimeMs]="300"> * Counter * </button> * ``` * * @example **Component (TypeScript)** * ```ts * @Component({ * standalone: true, * selector: 'app-foo', * imports: [ThrottleClickDirective] * }) * export class FooComponent { * onCounterClick(mouseEvent: MouseEvent): void { * // ... * } * } * ``` */ class ThrottleClickDirective { _element = inject(ElementRef); /** * Whether the first click is emitted - default is true */ leading = input(true); /** * Whether the last click is emitted - default is true */ trailing = input(true); throttleTimeMs = input(800); throttleClick = new EventEmitter(); constructor() { fromEvent(this._element.nativeElement, "click") .pipe(throttleTime(this.throttleTimeMs(), undefined, { leading: this.leading(), trailing: this.trailing() }), takeUntilDestroyed()) .subscribe((mouseEvent) => { this.throttleClick.emit(mouseEvent); }); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ThrottleClickDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "18.2.14", type: ThrottleClickDirective, isStandalone: true, selector: "[throttleClick]", inputs: { leading: { classPropertyName: "leading", publicName: "leading", isSignal: true, isRequired: false, transformFunction: null }, trailing: { classPropertyName: "trailing", publicName: "trailing", isSignal: true, isRequired: false, transformFunction: null }, throttleTimeMs: { classPropertyName: "throttleTimeMs", publicName: "throttleTimeMs", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { throttleClick: "throttleClick" }, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ThrottleClickDirective, decorators: [{ type: Directive, args: [{ selector: '[throttleClick]', standalone: true }] }], ctorParameters: () => [], propDecorators: { throttleClick: [{ type: Output }] } }); /* * Public API Surface of utils */ /** * Generated bundle index. Do not edit. */ export { CustomValidators, KeyboardKeyArrays, KeyboardKeys, StorageUtil, TextClampDirective, ThrottleClickDirective, TimestampUtil }; //# sourceMappingURL=ogs-gmbh-ngx-utils.mjs.map