UNPKG

@etsoo/shared

Version:

TypeScript shared utilities and functions

713 lines (712 loc) 25 kB
/// <reference lib="dom" /> import { DataTypes } from "./DataTypes"; import { DateUtils } from "./DateUtils"; import { Utils } from "./Utils"; if (typeof navigator === "undefined") { // Test mock only globalThis.navigator = { language: "en-US" }; globalThis.location = { href: "http://localhost/" }; } /** * Dom Utilities * Not all methods support Node */ export var DomUtils; (function (DomUtils) { /** * Language cache parameter name */ DomUtils.CultureField = "culture"; /** * Country cache parameter name */ DomUtils.CountryField = "country"; /** * Clear form data * @param data Form data * @param source Source data to match * @param keepFields Fields need to be kept */ function clearFormData(data, source, keepFields) { // Unique keys, FormData may have same name keys const keys = new Set(data.keys()); // Remove empty key const removeEmpty = (key) => { // Need to be kept if (keepFields != null && keepFields.includes(key)) return; // Get all values const formValues = data.getAll(key); if (formValues.length == 1 && formValues[0] === "") { // Remove empty field data.delete(key); } }; if (source == null) { // Remove all empty strings for (const key of keys) { removeEmpty(key); } } else { const sourceKeys = Object.keys(source); for (const key of sourceKeys) { // Need to be kept if (keepFields != null && keepFields.includes(key)) continue; // Get all values const formValues = data.getAll(key); if (formValues.length > 0) { // Matched // Source value const sourceValue = Reflect.get(source, key); if (Array.isArray(sourceValue)) { // Array, types may differ if (formValues.join("`") === sourceValue.join("`")) { // Equal value, remove the key data.delete(key); } } else if (formValues.length == 1) { // Other if (formValues[0].toString() === `${sourceValue}`) { // Equal value, remove the key data.delete(key); } } } } // Left fields for (const key of keys) { // Already cleared if (sourceKeys.includes(key)) continue; // Remove empties removeEmpty(key); } } // Return return data; } DomUtils.clearFormData = clearFormData; function dataAsTraveller(source, data, template, keepSource, isValue) { // Properties const properties = Object.keys(template); // Entries const entries = Object.entries(isFormData(source) ? formDataToObject(source) : source); for (const [key, value] of entries) { // Is included or keepSource const property = properties.find((p) => p.localeCompare(key, "en", { sensitivity: "base" }) === 0) ?? (keepSource ? key : undefined); if (property == null) continue; // Template value const templateValue = Reflect.get(template, property); // Formatted value let propertyValue; if (templateValue == null) { // Just read the source value propertyValue = value; } else { if (isValue) { // With template value propertyValue = DataTypes.convert(value, templateValue); } else { // With template type propertyValue = DataTypes.convertByType(value, templateValue); } } // Set value // Object.assign(data, { [property]: propertyValue }); // Object.defineProperty(data, property, { value: propertyValue }); Reflect.set(data, property, propertyValue); } } /** * Cast data as template format * @param source Source data * @param template Format template * @param keepSource Keep other source properties * @returns Result */ function dataAs(source, template, keepSource = false) { // New data // Object.create(...) const data = {}; if (source != null && typeof source === "object") { // Travel all properties dataAsTraveller(source, data, template, keepSource, false); } // Return return data; } DomUtils.dataAs = dataAs; /** * Cast data to target type * @param source Source data * @param template Template for generation * @param keepSource Means even the template does not include the definition, still keep the item * @returns Result */ function dataValueAs(source, templateValue, keepSource = false) { // New data // Object.create(...) const data = {}; // Travel all properties dataAsTraveller(source, data, templateValue, keepSource, true); // Return return data; } DomUtils.dataValueAs = dataValueAs; /** * Current detected country */ DomUtils.detectedCountry = (() => { // URL first, then local storage let country; try { country = new URL(location.href).searchParams.get(DomUtils.CountryField) ?? sessionStorage.getItem(DomUtils.CountryField) ?? localStorage.getItem(DomUtils.CountryField); } catch { country = null; } // Return return country; })(); /** * Current detected culture */ DomUtils.detectedCulture = (() => { // URL first, then local storage let culture; try { culture = new URL(location.href).searchParams.get(DomUtils.CultureField) ?? sessionStorage.getItem(DomUtils.CultureField) ?? localStorage.getItem(DomUtils.CultureField); } catch { culture = null; } // Browser detected if (culture == null) { culture = (navigator.languages && navigator.languages[0]) || navigator.language; } // Return return culture; })(); /** * Is two dimensions equal * @param d1 Dimension 1 * @param d2 Dimension 2 */ function dimensionEqual(d1, d2) { if (d1 == null && d2 == null) { return true; } if (d1 == null || d2 == null) { return false; } if (d1.left === d2.left && d1.top === d2.top && d1.right === d2.right && d1.bottom === d2.bottom) { return true; } return false; } DomUtils.dimensionEqual = dimensionEqual; /** * Download file from API fetch response body * @param data Data * @param suggestedName Suggested file name * @param autoDetect Auto detect, false will use link click way */ async function downloadFile(data, suggestedName, autoDetect = true) { try { if (autoDetect && "showSaveFilePicker" in globalThis) { // AbortError - Use dismisses the window const handle = await globalThis.showSaveFilePicker({ suggestedName }); if (!(await verifyPermission(handle, true))) return undefined; const stream = await handle.createWritable(); if (data instanceof Blob) { data.stream().pipeTo(stream); } else { await data.pipeTo(stream); } return true; } else { const url = window.URL.createObjectURL(data instanceof Blob ? data : await new Response(data).blob()); const a = document.createElement("a"); a.style.display = "none"; a.href = url; if (suggestedName) a.download = suggestedName; document.body.appendChild(a); a.click(); a.remove(); window.URL.revokeObjectURL(url); return true; } } catch (e) { console.error("DomUtils.downloadFile with error", e); } return false; } DomUtils.downloadFile = downloadFile; /** * File to data URL * @param file File * @returns Data URL */ async function fileToDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = reject; reader.onload = () => { const data = reader.result; if (data == null) { reject(); return; } resolve(data); }; reader.readAsDataURL(file); }); } DomUtils.fileToDataURL = fileToDataURL; /** * Form data to object * @param form Form data * @returns Object */ function formDataToObject(form) { const dic = {}; for (const key of new Set(form.keys())) { const values = form.getAll(key); dic[key] = values.length == 1 ? values[0] : values; } return dic; } DomUtils.formDataToObject = formDataToObject; /** * Is wechat client * @param data User agent data * @returns Result */ function isWechatClient(data) { data ?? (data = parseUserAgent()); if (!data) return false; return data.brands.some((item) => item.brand.toLowerCase() === "micromessenger"); } DomUtils.isWechatClient = isWechatClient; /** * Culture match case Enum */ let CultureMatch; (function (CultureMatch) { CultureMatch[CultureMatch["Exact"] = 0] = "Exact"; CultureMatch[CultureMatch["Compatible"] = 1] = "Compatible"; CultureMatch[CultureMatch["SamePart"] = 2] = "SamePart"; CultureMatch[CultureMatch["Default"] = 3] = "Default"; })(CultureMatch = DomUtils.CultureMatch || (DomUtils.CultureMatch = {})); /** * Get English resources definition * @param resources Resources * @returns Result */ DomUtils.en = (resources) => ({ name: "en", label: "English", resources }); /** * Get simplified Chinese resources definition * @param resources Resources * @returns Result */ DomUtils.zhHans = (resources) => ({ name: "zh-Hans", label: "简体中文", resources, compatibleNames: ["zh-CN", "zh-SG"] }); /** * Get traditional Chinese resources definition * @param resources Resources * @returns Result */ DomUtils.zhHant = (resources) => ({ name: "zh-Hant", label: "繁體中文", resources, compatibleNames: ["zh-HK", "zh-TW", "zh-MO"] }); /** * Get the available culture definition * @param items Available cultures * @param culture Detected culture */ DomUtils.getCulture = (items, culture) => { if (items.length === 0) { throw new Error("Culture items cannot be empty"); } // Exact match const exactMatch = items.find((item) => item.name === culture); if (exactMatch) return [exactMatch, CultureMatch.Exact]; // Compatible match const compatibleMatch = items.find((item) => item.compatibleNames?.includes(culture) || culture.startsWith(item + "-")); if (compatibleMatch) return [compatibleMatch, CultureMatch.Compatible]; // Same part, like zh-CN and zh-HK const samePart = culture.split("-")[0]; const samePartMatch = items.find((item) => item.name.startsWith(samePart)); if (samePartMatch) return [samePartMatch, CultureMatch.SamePart]; // Default return [items[0], CultureMatch.Default]; }; /** * Get input value depending on its type * @param input HTML input * @returns Result */ function getInputValue(input) { const type = input.type; if (type === "number" || type === "range") { const num = input.valueAsNumber; if (isNaN(num)) return null; return num; } else if (type === "date" || type === "datetime-local") return input.valueAsDate ?? DateUtils.parse(input.value); return input.value; } DomUtils.getInputValue = getInputValue; /** * Get an unique key combined with current URL * @param key Key */ DomUtils.getLocationKey = (key) => `${location.href}:${key}`; function isIterable(headers) { return Symbol.iterator in headers; } /** * Convert headers to object * @param headers Heaers */ function headersToObject(headers) { if (Array.isArray(headers)) { return Object.fromEntries(headers); } if (typeof Headers === "undefined") { return Object.fromEntries(Object.entries(headers)); } if (headers instanceof Headers) { return Object.fromEntries(headers.entries()); } if (isIterable(headers)) { return Object.fromEntries(headers); } return headers; } DomUtils.headersToObject = headersToObject; /** * Is IFormData type guard * @param input Input object * @returns result */ function isFormData(input) { if (typeof input === "object" && input != null && "entries" in input && "getAll" in input && "keys" in input) { return true; } return false; } DomUtils.isFormData = isFormData; /** * Is JSON content type * @param contentType Content type string */ function isJSONContentType(contentType) { if (contentType && // application/problem+json // application/json (contentType.includes("json") || contentType.startsWith("application/javascript"))) return true; return false; } DomUtils.isJSONContentType = isJSONContentType; /** * Merge form data to primary one * @param form Primary form data * @param forms Other form data * @returns Merged form data */ function mergeFormData(form, ...forms) { for (const newForm of forms) { for (const key of new Set(newForm.keys())) { form.delete(key); newForm.getAll(key).forEach((value) => form.append(key, value)); } } return form; } DomUtils.mergeFormData = mergeFormData; /** * Merge URL search parameters * @param base URL search parameters * @param data New simple object data to merge */ function mergeURLSearchParams(base, data) { Object.entries(data).forEach(([key, value]) => { if (value == null) return; base.set(key, value.toString()); }); return base; } DomUtils.mergeURLSearchParams = mergeURLSearchParams; /** * Parse navigator's user agent string * Lightweight User-Agent string parser * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent * @param ua User agent string * @returns User agent data */ function parseUserAgent(ua) { ua ?? (ua = globalThis.navigator.userAgent); if (!ua) { return null; } const parts = ua.split(/(?!\(.*)\s+(?!\()(?![^(]*?\))/g); let mobile = false; let platform = ""; let platformVersion; let device = "Desktop"; const brands = []; // with the 'g' will causing failures for multiple calls const platformVersionReg = /^[a-zA-Z0-9-\s]+\s+(0|\d+)(\.(0|\d+)){0,3}(\(|$)/; const versionReg = /^[a-zA-Z0-9]+\/(0|\d+)(\.(0|\d+)){0,3}(\(|$)/; parts.forEach((part) => { const pl = part.toLowerCase(); if (pl.startsWith("mozilla/")) { const data = /\((.*)\)$/.exec(part); if (data && data.length > 1) { const pfItems = data[1].split(/;\s*/); // Platform + Version const pfIndex = pfItems.findIndex((item) => platformVersionReg.test(item)); if (pfIndex !== -1) { const pfParts = pfItems[pfIndex].split(/\s+/); platformVersion = pfParts.pop(); platform = pfParts.join(" "); } else { const appleVersionReg = /((iPhone|Mac)\s+OS(\s+\w+)?)\s+((0|\d+)(_(0|\d+)){0,3})/i; for (let i = 0; i < pfItems.length; i++) { const match = appleVersionReg.exec(pfItems[i]); if (match && match.length > 4) { platform = match[1]; platformVersion = match[4].replace(/_/g, "."); pfItems.splice(i, 1); break; } } } // Device const deviceIndex = pfItems.findIndex((item) => item.includes(" Build/")); if (deviceIndex === -1) { const firstItem = pfItems[0]; if (firstItem.toLowerCase() !== "linux" && !firstItem.startsWith(platform)) { device = firstItem; pfItems.shift(); } } else { device = pfItems[deviceIndex].split(" Build/")[0]; pfItems.splice(deviceIndex, 1); } } return; } if (pl === "mobile" || pl.startsWith("mobile/")) { mobile = true; return; } if (pl === "version" || pl.startsWith("version/")) { // No process return; } if (versionReg.test(part)) { let [brand, version] = part.split("/"); const pindex = version.indexOf("("); if (pindex > 0) { version = version.substring(0, pindex); } brands.push({ brand, version: Utils.trimEnd(version, ".0") }); return; } }); return { mobile, platform, platformVersion, brands, device }; } DomUtils.parseUserAgent = parseUserAgent; /** * Set HTML element focus by name * @param name Element name or first collection item * @param container Container, limits the element range */ function setFocus(name, container) { const elementName = typeof name === "string" ? name : Object.keys(name)[0]; container ?? (container = document.body); const element = container.querySelector(`[name="${elementName}"]`); if (element != null) element.focus(); } DomUtils.setFocus = setFocus; /** * Setup frontend logging * @param action Logging action * @param preventDefault Is prevent default action * @param window Window object */ function setupLogging(action, preventDefault, window = globalThis.window) { // Avoid multiple setup, if there is already a handler, please set "window.onunhandledrejection = null" first if (window.onunhandledrejection) return; const errorType = "error"; const errorPD = Utils.getResult(preventDefault, errorType) ?? true; window.onerror = (message, source, lineNo, colNo, error) => { // Default source source || (source = window.location.href); let data; if (typeof message === "string") { data = { type: errorType, message, // Share the same message with error source, lineNo, colNo, stack: error?.stack }; } else { data = { type: errorType, subType: message.type, message: error?.message ?? `${message.currentTarget} event error`, source, lineNo, colNo, stack: error?.stack }; } action(data); // Return true to suppress error alert return errorPD; }; const rejectionType = "unhandledrejection"; const rejectionPD = Utils.getResult(preventDefault, rejectionType) ?? true; window.onunhandledrejection = (event) => { if (rejectionPD) event.preventDefault(); const reason = event.reason; const source = window.location.href; let data; if (reason instanceof Error) { const { name: subType, message, stack } = reason; data = { type: rejectionType, subType, message, stack, source }; } else { data = { type: rejectionType, message: typeof reason === "string" ? reason : JSON.stringify(reason), source }; } action(data); }; const localConsole = (type, orgin) => { const consolePD = Utils.getResult(preventDefault, type) ?? false; return (...args) => { // Keep original action if (!consolePD) orgin(...args); const [first, ...rest] = args; let message; if (typeof first === "string") { message = first; } else { message = JSON.stringify(first); } const stack = rest.length > 0 ? rest.map((item) => JSON.stringify(item)).join(", ") : undefined; const data = { type, message, source: window.location.href, stack }; action(data); }; }; window.console.warn = localConsole("consoleWarn", window.console.warn); window.console.error = localConsole("consoleError", window.console.error); } DomUtils.setupLogging = setupLogging; /** * Verify file system permission * https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle/requestPermission * @param fileHandle FileSystemHandle * @param withWrite With write permission * @returns Result */ async function verifyPermission(fileHandle, withWrite = false) { if (!("queryPermission" in fileHandle) || !("requestPermission" in fileHandle)) return false; // FileSystemHandlePermissionDescriptor const opts = { mode: withWrite ? "readwrite" : "read" }; // Check if we already have permission, if so, return true. if ((await fileHandle.queryPermission(opts)) === "granted") { return true; } // Request permission to the file, if the user grants permission, return true. if ((await fileHandle.requestPermission(opts)) === "granted") { return true; } // The user did not grant permission, return false. return false; } DomUtils.verifyPermission = verifyPermission; })(DomUtils || (DomUtils = {}));