UNPKG

x4js

Version:

X4 framework

907 lines (724 loc) 22.5 kB
/** * ___ ___ __ * \ \/ / / _ * \ / /_| |_ * / \____ _| * /__/\__\ |_| * * @file core_tools.ts * @author Etienne Cochard * * @copyright (c) 2024 R-libre ingenierie * * Use of this source code is governed by an MIT-style license * that can be found in the LICENSE file or at https://opensource.org/licenses/MIT. **/ import { _tr } from "./core_i18n.js"; /** * @returns true if object is a string */ export function isString(val: any): val is string { return typeof val === 'string'; } /** * @returns true if object is a number */ export function isNumber(v: any): v is number { return typeof v === 'number' && isFinite(v); } /** * @returns true if object is an array */ export function isArray(val: any): val is any[] { return val instanceof Array; } /** * @returns true if object is a function */ export function isFunction(val: any): val is Function { return val instanceof Function; } /** * generic constructor */ export type Constructor<P> = { new(...params: any[]): P; }; /** * a way to explain that the given string may be unsafe but must be treated a sstring * @example * label.setText( unsafehtml`<b>Bold</b> text` ); * label.setText( new UnsafeHtml("<b>Bold</b> text`" ) ); */ export class UnsafeHtml extends String { constructor(value: string) { super(value); } } export function unsafeHtml(x: string): UnsafeHtml { return new UnsafeHtml(x); } export function unsafe(strings: TemplateStringsArray, ...values: any[]): UnsafeHtml { const result = strings.reduce((acc, str, i) => { return acc + str + (values[i] || ''); }, ''); return unsafeHtml(result); } /** * */ export function clamp<T>(v: T, min: T, max: T): T { if (v < min) { return min; } if (v > max) { return max; } return v; } /** * generic Rectangle */ export interface IRect { left: number; top: number; height: number; width: number; } /** * */ export class Rect implements IRect { left: number; top: number; height: number; width: number; constructor(); constructor(l: number, t: number, w: number, h: number); constructor(l: Rect); constructor(l?: number | IRect, t?: number, w?: number, h?: number) { if (l !== undefined) { if (isNumber(l)) { this.left = l; this.top = t; this.width = w; this.height = h; } else { Object.assign(this, l); } } } get right() { return this.left + this.width; } get bottom() { return this.top + this.height; } contains(pt: Point): boolean; contains(rc: Rect): boolean; contains(arg: any): boolean { if (arg instanceof Rect) { return arg.left >= this.left && arg.right <= this.right && arg.top >= this.top && arg.bottom <= this.bottom; } else { return arg.x >= this.left && arg.x < this.right && arg.y >= this.top && arg.y < this.bottom; } } touches(rc: Rect): boolean { if (this.left > rc.right || this.right < rc.left || this.top > rc.bottom || this.bottom < rc.top) { return false; } return true; } normalize(): this { let w = this.width, h = this.height; if (w < 0) { this.left += w; this.width = -w; } if (h < 0) { this.top += h; this.height = -h; } return this; } } /** * generic size */ export interface Size { w: number; h: number; } /** * generic Point * TODO: IPoint */ export interface Point { x: number; y: number; } /** * generic Size * TODO: ISize */ export interface Size { w: number; h: number; } /** * center one rect inside another */ export function centerRect(innerRect: IRect, outerRect: IRect, margin: number = 0): IRect { const owidth = outerRect.width - 2 * margin; const oheight = outerRect.height - 2 * margin; const ratio = innerRect.width / innerRect.height; let nwidth = owidth; let nheight = owidth / ratio; if (nheight > oheight) { nheight = oheight; nwidth = oheight * ratio; } const newLeft = outerRect.left + (outerRect.width - nwidth) / 2; const newTop = outerRect.top + (outerRect.height - nheight) / 2; return { left: newLeft, top: newTop, width: nwidth, height: nheight }; } /** * @see queryInterface */ export interface IComponentInterface { } // form-element export interface IFormElement extends IComponentInterface { getRawValue(): any; setRawValue(v: any): void; isValid(): boolean; } // tab-handler export interface ITabHandler extends IComponentInterface { focusNext(next: boolean): boolean; // return true to stop event } // tip-handler export interface ITipHandler extends IComponentInterface { getTip(): string; } /** * */ interface Features { eyedropper: 1, } export function isFeatureAvailable(name: keyof Features): boolean { switch (name) { case "eyedropper": return "EyeDropper" in window; } return false; } export class Timer { protected _timers: Map<string, any>; /** * */ setTimeout(name: string, time: number, callback: Function) { if (!this._timers) { this._timers = new Map(); } else { this.clearTimeout(name); } const tm = setTimeout(callback, time); this._timers.set(name, tm); return tm; } clearTimeout(name: string) { if (this._timers && this._timers.has(name)) { clearTimeout(this._timers.get(name)); this._timers.delete(name); } } /** * */ setInterval(name: string, time: number, callback: Function) { if (!this._timers) { this._timers = new Map(); } else { this.clearInterval(name); } const tm = setInterval(callback, time); this._timers.set(name, tm); return tm; } clearInterval(name: string) { if (this._timers && this._timers.has(name)) { clearInterval(this._timers.get(name)); this._timers.delete(name); } } clearAllTimeouts() { this._timers?.forEach(t => { clearTimeout(t); }); this._timers = null; } } /** * */ export function asap(callback: () => void) { return requestAnimationFrame(callback); } export function oneshot(callback: () => void, ms = 0) { return setTimeout(callback, ms); } // :: STRING UTILS :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: /** * prepend 0 to a value to a given length * @param value * @param length */ export function pad(what: any, size: number, ch: string = '0') { let value: string; if (!isString(what)) { value = '' + what; } else { value = what; } if (size > 0) { return value.padEnd(size, ch); } else { return value.padStart(-size, ch); } } /** * replace {0..9} by given arguments * @param format string * @param args * * @example ```ts * * console.log( sprintf( 'here is arg 1 {1} and arg 0 {0}', 'argument 0', 'argument 1' ) ) */ export function sprintf(format: string, ...args: any[]) { return format.replace(/{(\d+)}/g, function (match, index) { return typeof args[index] != 'undefined' ? args[index] : match; }); } /** * inverse of camel case * theThingToCase -> the-thing-to-case * @param {String} str */ export function pascalCase(string: string): string { let result = string; result = result.replace(/([a-z])([A-Z])/g, "$1 $2"); result = result.toLowerCase(); result = result.replace(/[^- a-z0-9]+/g, ' '); if (result.indexOf(' ') < 0) { return result; } result = result.trim(); return result.replace(/ /g, '-'); } export function camelCase(text: string) { let result = text.toLowerCase(); result = result.replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => { return chr.toUpperCase(); }); return result; } // :: DATES :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: let cur_locale: string = 'fr-FR'; /** * change the current locale for misc translations (date...) * @param locale */ export function _date_set_locale(locale: string) { cur_locale = locale; } /** * * @param date * @param options * @example * let date = new Date( ); * let options = { day: 'numeric', month: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }; * let text = date_format( date, options ); */ export function date_format(date: Date, options?: any): string { //return new Intl.DateTimeFormat(cur_locale, options).format( date ); return formatIntlDate(date); } /** * * @param date * @param options */ export function date_diff(date1: Date, date2: Date, options?: any): string { let dt = (date1.getTime() - date2.getTime()) / 1000; // seconds let sec = dt; if (sec < 60) { return sprintf(_tr.global.diff_date_seconds, Math.round(sec)); } // minutes let min = Math.floor(sec / 60); if (min < 60) { return sprintf(_tr.global.diff_date_minutes, Math.round(min)); } // hours let hrs = Math.floor(min / 60); return sprintf(_tr.global.diff_date_hours, hrs, min % 60); } export function date_to_sql(date: Date, withHours: boolean) { if (withHours) { return formatIntlDate(date, 'Y-M-D H:I:S'); } else { return formatIntlDate(date, 'Y-M-D'); } } /** * construct a date from an utc date time (sql format) * YYYY-MM-DD HH:MM:SS */ export function date_sql_utc(date: string): Date { let result = new Date(date + ' GMT'); return result; } /** * return a number that is a representation of the date * this number can be compared with another hash */ export function date_hash(date: Date): number { return date.getFullYear() << 16 | date.getMonth() << 8 | date.getDate(); } /** * return a copy of a date */ export function date_clone(date: Date): Date { return new Date(date.getTime()); } /** * return the week number of a date */ export function date_calc_weeknum(date: Date): number { const firstDayOfYear = new Date(date.getFullYear(), 0, 1); const pastDaysOfYear = (date.valueOf() - firstDayOfYear.valueOf()) / 86400000; return Math.floor((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7); } /** * parse a date according to the given format * @param value - string date to parse * @param fmts - format list - i18 tranlation by default * allowed format specifiers: * d: date (1 or 2 digits) * D: date (2 digits) * m: month (1 or 2 digits) * M: month (2 digits) * y: year (2 to 4 digits) * Y: year (2 digits) * YY: year (4 digits) * h: hours (1 or 2 digits) * H: hours (2 digits) * i: minutes (1 or 2 digits) * I: minutes (2 digits) * s: seconds (1 or 2 digits) * S: seconds (2 digits) * <space>: 1 or more spaces * any other char: <0 or more spaces><the char><0 or more spaces> * each specifiers is separated from other by a pipe (|) * more specific at first * @example * 'd/m/y|d m Y|dmy|y-m-d h:i:s|y-m-d|YY-M-D' */ export function parseIntlDate(value: string, fmts: string = _tr.global.date_input_formats): Date { let formats = fmts.split('|'); for (let fmatch of formats) { //review: could do that only once & keep result //review: add hours, minutes, seconds let smatch = ''; for (let i = 0; i < fmatch.length; i++) { const c = fmatch[i]; if (c == 'd') { smatch += '(?<day>\\d{1,2})'; } else if (c == 'D') { smatch += '(?<day>\\d{2})'; } else if (c == 'm') { smatch += '(?<month>\\d{1,2})'; } else if (c == 'M') { smatch += '(?<month>\\d{2})'; } else if (c == 'y') { smatch += '(?<year>\\d{1,4})'; } else if (c == 'Y') { if (fmatch[i + 1] == 'Y') { smatch += '(?<year>\\d{4})'; i++; } else { smatch += '(?<year>\\d{2})'; } } else if (c == 'h') { smatch += '(?<hour>\\d{1,2})'; } else if (c == 'H') { smatch += '(?<hour>\\d{2})'; } else if (c == 'i') { smatch += '(?<min>\\d{1,2})'; } else if (c == 'I') { smatch += '(?<min>\\d{2})'; } else if (c == 's') { smatch += '(?<sec>\\d{1,2})'; } else if (c == 'S') { smatch += '(?<sec>\\d{2})'; } else if (c == ' ') { smatch += '\\s+'; } else { smatch += '\\s*\\' + c + '\\s*'; } } let rematch = new RegExp('^' + smatch + '$', 'm'); let match = rematch.exec(value); if (match) { const now = new Date(); let d = parseInt(match.groups.day ?? '1'); let m = parseInt(match.groups.month ?? '1'); let y = parseInt(match.groups.year ?? now.getFullYear() + ''); let h = parseInt(match.groups.hour ?? '0'); let i = parseInt(match.groups.min ?? '0'); let s = parseInt(match.groups.sec ?? '0'); if (y > 0 && y < 100) { y += 2000; } let result = new Date(y, m - 1, d, h, i, s, 0); // we test the vdate validity (without adjustments) // without this test, date ( 0, 0, 0) is accepted and transformed to 1969/11/31 (not fun) let ty = result.getFullYear(), tm = result.getMonth() + 1, td = result.getDate(); if (ty != y || tm != m || td != d) { //debugger; return null; } return result; } } return null; } /** * format a date as string * @param date - date to format * @param fmt - format * format specifiers: * - d: date (no pad) * - D: 2 digits date padded with 0 * - j: day of week short mode 'mon' * - J: day of week long mode 'monday' * - w: week number * - m: month (no pad) * - M: 2 digits month padded with 0 * - o: month short mode 'jan' * - O: month long mode 'january' * - y or Y: year * - h: hour (24 format) * - H: 2 digits hour (24 format) padded with 0 * - i: minutes * - I: 2 digits minutes padded with 0 * - s: seconds * - S: 2 digits seconds padded with 0 * - a: am or pm * - anything else is inserted * - if you need to insert some text, put it between {} * * @example * * 01/01/1970 11:25:00 with '{this is my demo date formatter: }H-i*M' * "this is my demo date formatter: 11-25*january" */ export function formatIntlDate(date: Date, fmt: string = _tr.global.date_format) { if (!date) { return ''; } let now = { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), wday: date.getDay(), hours: date.getHours(), minutes: date.getMinutes(), seconds: date.getSeconds(), milli: date.getMilliseconds() }; let result = ''; let esc = 0; for (let c of fmt) { if (c == '{') { if (++esc == 1) { continue; } } else if (c == '}') { if (--esc == 0) { continue; } } if (esc) { result += c; continue; } if (c == 'd') { result += now.day; } else if (c == 'D') { result += pad(now.day, -2); } else if (c == 'j') { // day short result += _tr.global.day_short[now.wday]; } else if (c == 'J') { // day long result += _tr.global.day_long[now.wday]; } else if (c == 'w') { // week result += date_calc_weeknum(date); } else if (c == 'W') { // week result += pad(date_calc_weeknum(date), -2); } else if (c == 'm') { result += now.month; } else if (c == 'M') { result += pad(now.month, -2); } else if (c == 'o') { // month short result += _tr.global.month_short[now.month - 1]; } else if (c == 'O') { // month long result += _tr.global.month_long[now.month - 1]; } else if (c == 'y' || c == 'Y') { result += pad(now.year, -4); } else if (c == 'a' || c == 'A') { result += now.hours < 12 ? 'am' : 'pm'; } else if (c == 'h') { result += now.hours; } else if (c == 'H') { result += pad(now.hours, -2); } else if (c == 'i') { result += now.minutes; } else if (c == 'I') { result += pad(now.minutes, -2); } else if (c == 's') { result += now.seconds; } else if (c == 'S') { result += pad(now.seconds, -2); } else if (c == 'l') { result += now.milli; } else if (c == 'L') { result += pad(now.milli, -3); } else { result += c; } } return result; } export function calcAge(birth: Date, ref?: Date) { if (ref === undefined) { ref = new Date(); } if (!birth) { return 0; } let age = ref.getFullYear() - birth.getFullYear(); if (ref.getMonth() < birth.getMonth() || (ref.getMonth() == birth.getMonth() && ref.getDate() < birth.getDate())) { age--; } return age; } // :: MISC :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: export function beep() { const snd = new Audio("data:audio/wav;base64,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qJrx4KOQjahZxWbcZgztj2c49nKmkId44S71j0c8eV9yDK6uPRzx5X18eDvjvQ6yKo9ZSS6l//8elePK/Lf//IInrOF/FvDoADYAGBMGb7FtErm5MXMlmPAJQVgWta7Zx2go+8xJ0UiCb8LHHdftWyLJE0QIAIsI+UbXu67dZMjmgDGCGl1H+vpF4NSDckSIkk7Vd+sxEhBQMRU8j/12UIRhzSaUdQ+rQU5kGeFxm+hb1oh6pWWmv3uvmReDl0UnvtapVaIzo1jZbf/pD6ElLqSX+rUmOQNpJFa/r+sa4e/pBlAABoAAAAA3CUgShLdGIxsY7AUABPRrgCABdDuQ5GC7DqPQCgbbJUAoRSUj+NIEig0YfyWUho1VBBBA//uQZB4ABZx5zfMakeAAAAmwAAAAF5F3P0w9GtAAACfAAAAAwLhMDmAYWMgVEG1U0FIGCBgXBXAtfMH10000EEEEEECUBYln03TTTdNBDZopopYvrTTdNa325mImNg3TTPV9q3pmY0xoO6bv3r00y+IDGid/9aaaZTGMuj9mpu9Mpio1dXrr5HERTZSmqU36A3CumzN/9Robv/Xx4v9ijkSRSNLQhAWumap82WRSBUqXStV/YcS+XVLnSS+WLDroqArFkMEsAS+eWmrUzrO0oEmE40RlMZ5+ODIkAyKAGUwZ3mVKmcamcJnMW26MRPgUw6j+LkhyHGVGYjSUUKNpuJUQoOIAyDvEyG8S5yfK6dhZc0Tx1KI/gviKL6qvvFs1+bWtaz58uUNnryq6kt5RzOCkPWlVqVX2a/EEBUdU1KrXLf40GoiiFXK///qpoiDXrOgqDR38JB0bw7SoL+ZB9o1RCkQjQ2CBYZKd/+VJxZRRZlqSkKiws0WFxUyCwsKiMy7hUVFhIaCrNQsKkTIsLivwKKigsj8XYlwt/WKi2N4d//uQRCSAAjURNIHpMZBGYiaQPSYyAAABLAAAAAAAACWAAAAApUF/Mg+0aohSIRobBAsMlO//Kk4soosy1JSFRYWaLC4qZBYWFRGZdwqKiwkNBVmoWFSJkWFxX4FFRQWR+LsS4W/rFRb/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////VEFHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAU291bmRib3kuZGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMjAwNGh0dHA6Ly93d3cuc291bmRib3kuZGUAAAAAAAAAACU="); snd.play(); } let sb_width_cache = -1; /** * compute scrollbar size */ export function getScrollbarSize() { if (sb_width_cache < 0) { let outerDiv = document.createElement('div'); outerDiv.style.cssText = 'overflow:auto;position:absolute;top:0;width:100px;height:100px'; let innerDiv = document.createElement('div'); innerDiv.style.width = '200px'; innerDiv.style.height = '200px'; outerDiv.appendChild(innerDiv); document.body.appendChild(outerDiv); sb_width_cache = outerDiv.offsetWidth - outerDiv.clientWidth; document.body.removeChild(outerDiv); } return sb_width_cache; } /** * */ export const x4_class_ns_sym = Symbol("class-ns"); export function class_ns(ns: string) { return function (constructor: Function) { (constructor as any)[x4_class_ns_sym] = ns; } } /** * */ export function setWaitCursor(wait: boolean) { document.body.style.cursor = wait ? "wait" : "default"; } /** * return the focusable elements from a given node */ export function getFocusableElements(root: Element) { const els = [ 'button:not([tabindex="-1"]):not([disabled])', '[href]', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])' ] const focusable = Array.from(root.querySelectorAll(els.join(','))); return focusable.filter(x => (x as HTMLElement).offsetParent != null); // check visibility } /** * */ export enum kbNav { first, prev, pgdn, pgup, next, last, }