@ogs-gmbh/ngx-utils
Version:
A lightweight collection of utility functions and helpers for Angular applications
531 lines (520 loc) • 20.5 kB
JavaScript
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