mirror of
https://github.com/codex-team/editor.js
synced 2026-03-15 23:25:47 +01:00
- Refactored inline tool interfaces to use MenuConfig directly. - Removed deprecated methods and properties from InlineTool and related types. - Updated tests to reflect changes in inline tool handling and ensure proper functionality. - Enhanced test coverage for inline tools, including link and italic tools. - Cleaned up unused code and improved overall test structure.
915 lines
23 KiB
TypeScript
915 lines
23 KiB
TypeScript
/**
|
|
* Class Util
|
|
*/
|
|
|
|
import { nanoid } from 'nanoid';
|
|
import lodashDelay from 'lodash/delay';
|
|
import lodashIsBoolean from 'lodash/isBoolean';
|
|
import lodashIsEmpty from 'lodash/isEmpty';
|
|
import lodashIsEqual from 'lodash/isEqual';
|
|
import lodashIsFunction from 'lodash/isFunction';
|
|
import lodashIsNumber from 'lodash/isNumber';
|
|
import lodashIsPlainObject from 'lodash/isPlainObject';
|
|
import lodashIsString from 'lodash/isString';
|
|
import lodashIsUndefined from 'lodash/isUndefined';
|
|
import lodashMergeWith from 'lodash/mergeWith';
|
|
import lodashThrottle from 'lodash/throttle';
|
|
import lodashToArray from 'lodash/toArray';
|
|
|
|
/**
|
|
* Possible log levels
|
|
*/
|
|
export enum LogLevels {
|
|
VERBOSE = 'VERBOSE',
|
|
INFO = 'INFO',
|
|
WARN = 'WARN',
|
|
ERROR = 'ERROR',
|
|
}
|
|
|
|
/**
|
|
* Allow to use global VERSION, that will be overwritten by Webpack
|
|
*/
|
|
declare const VERSION: string;
|
|
|
|
const fallbackEditorVersion = 'dev';
|
|
|
|
/**
|
|
* Returns Editor.js version injected by bundler or a globally provided fallback.
|
|
*/
|
|
export const getEditorVersion = (): string => {
|
|
if (typeof VERSION !== 'undefined') {
|
|
return VERSION;
|
|
}
|
|
|
|
const globalVersion = (typeof globalThis === 'object' && globalThis !== null)
|
|
? (globalThis as { VERSION?: unknown }).VERSION
|
|
: undefined;
|
|
|
|
if (typeof globalVersion === 'string' && globalVersion.trim() !== '') {
|
|
return globalVersion;
|
|
}
|
|
|
|
return fallbackEditorVersion;
|
|
};
|
|
|
|
|
|
/**
|
|
* Editor.js utils
|
|
*/
|
|
|
|
/**
|
|
* Returns basic key codes as constants
|
|
*
|
|
* @returns {{}}
|
|
*/
|
|
export const keyCodes = {
|
|
BACKSPACE: 8,
|
|
TAB: 9,
|
|
ENTER: 13,
|
|
SHIFT: 16,
|
|
CTRL: 17,
|
|
ALT: 18,
|
|
ESC: 27,
|
|
SPACE: 32,
|
|
LEFT: 37,
|
|
UP: 38,
|
|
DOWN: 40,
|
|
RIGHT: 39,
|
|
DELETE: 46,
|
|
// Number keys range (0-9)
|
|
NUMBER_KEY_MIN: 47,
|
|
NUMBER_KEY_MAX: 58,
|
|
// Letter keys range (A-Z)
|
|
LETTER_KEY_MIN: 64,
|
|
LETTER_KEY_MAX: 91,
|
|
META: 91,
|
|
// Numpad keys range
|
|
NUMPAD_KEY_MIN: 95,
|
|
NUMPAD_KEY_MAX: 112,
|
|
// Punctuation keys range (;=,-./`)
|
|
PUNCTUATION_KEY_MIN: 185,
|
|
PUNCTUATION_KEY_MAX: 193,
|
|
// Bracket keys range ([\]')
|
|
BRACKET_KEY_MIN: 218,
|
|
BRACKET_KEY_MAX: 223,
|
|
// Processing key input for certain languages (Chinese, Japanese, etc.)
|
|
PROCESSING_KEY: 229,
|
|
SLASH: 191,
|
|
};
|
|
|
|
/**
|
|
* Return mouse buttons codes
|
|
*/
|
|
export const mouseButtons = {
|
|
LEFT: 0,
|
|
WHEEL: 1,
|
|
RIGHT: 2,
|
|
BACKWARD: 3,
|
|
FORWARD: 4,
|
|
};
|
|
|
|
/**
|
|
* Constants for ID generation
|
|
*/
|
|
const ID_RANDOM_MULTIPLIER = 100_000_000; // 1e8
|
|
const HEXADECIMAL_RADIX = 16;
|
|
|
|
type UniversalScope = {
|
|
window?: Window;
|
|
document?: Document;
|
|
navigator?: Navigator;
|
|
};
|
|
|
|
const globalScope: UniversalScope | undefined = (() => {
|
|
try {
|
|
return Function('return this')() as UniversalScope;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
})();
|
|
|
|
if (globalScope && typeof globalScope.window === 'undefined') {
|
|
(globalScope as Record<string, unknown>).window = globalScope;
|
|
}
|
|
|
|
/**
|
|
* Returns globally available window object if it exists.
|
|
*/
|
|
const getGlobalWindow = (): Window | undefined => {
|
|
if (globalScope?.window) {
|
|
return globalScope.window;
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
/**
|
|
* Returns globally available navigator object if it exists.
|
|
*/
|
|
const getGlobalNavigator = (): Navigator | undefined => {
|
|
if (globalScope?.navigator) {
|
|
return globalScope.navigator;
|
|
}
|
|
|
|
const win = getGlobalWindow();
|
|
|
|
return win?.navigator;
|
|
};
|
|
|
|
/**
|
|
* Type representing callable console methods
|
|
*/
|
|
type ConsoleMethod = {
|
|
[K in keyof Console]: Console[K] extends (...args: unknown[]) => unknown ? K : never;
|
|
}[keyof Console];
|
|
|
|
/**
|
|
* Custom logger
|
|
*
|
|
* @param {boolean} labeled — if true, Editor.js label is shown
|
|
* @param {string} msg - message
|
|
* @param {string} type - logging type 'log'|'warn'|'error'|'info'
|
|
* @param {*} [args] - argument to log with a message
|
|
* @param {string} style - additional styling to message
|
|
*/
|
|
const _log = (
|
|
labeled: boolean,
|
|
msg: string,
|
|
type: ConsoleMethod = 'log',
|
|
args?: unknown,
|
|
style = 'color: inherit'
|
|
): void => {
|
|
const consoleRef: Console | undefined = typeof console === 'undefined' ? undefined : console;
|
|
|
|
if (!consoleRef || typeof consoleRef[type] !== 'function') {
|
|
return;
|
|
}
|
|
|
|
const isSimpleType = ['info', 'log', 'warn', 'error'].includes(type);
|
|
const argsToPass: unknown[] = [];
|
|
|
|
switch (_log.logLevel) {
|
|
case LogLevels.ERROR:
|
|
if (type !== 'error') {
|
|
return;
|
|
}
|
|
break;
|
|
|
|
case LogLevels.WARN:
|
|
if (!['error', 'warn'].includes(type)) {
|
|
return;
|
|
}
|
|
break;
|
|
|
|
case LogLevels.INFO:
|
|
if (!isSimpleType || labeled) {
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (args) {
|
|
argsToPass.push(args);
|
|
}
|
|
|
|
const editorLabelText = `Editor.js ${getEditorVersion()}`;
|
|
const editorLabelStyle = `line-height: 1em;
|
|
color: #006FEA;
|
|
display: inline-block;
|
|
font-size: 11px;
|
|
line-height: 1em;
|
|
background-color: #fff;
|
|
padding: 4px 9px;
|
|
border-radius: 30px;
|
|
border: 1px solid rgba(56, 138, 229, 0.16);
|
|
margin: 4px 5px 4px 0;`;
|
|
|
|
const formattedMessage = (() => {
|
|
if (!labeled) {
|
|
return msg;
|
|
}
|
|
|
|
if (isSimpleType) {
|
|
argsToPass.unshift(editorLabelStyle, style);
|
|
|
|
return `%c${editorLabelText}%c ${msg}`;
|
|
}
|
|
|
|
return `( ${editorLabelText} )${msg}`;
|
|
})();
|
|
|
|
const callArguments = (() => {
|
|
if (!isSimpleType) {
|
|
return [ formattedMessage ];
|
|
}
|
|
|
|
if (args !== undefined) {
|
|
return [`${formattedMessage} %o`, ...argsToPass];
|
|
}
|
|
|
|
return [formattedMessage, ...argsToPass];
|
|
})();
|
|
|
|
try {
|
|
consoleRef[type](...callArguments);
|
|
} catch (ignored) {}
|
|
};
|
|
|
|
/**
|
|
* Current log level
|
|
*/
|
|
_log.logLevel = LogLevels.VERBOSE;
|
|
|
|
/**
|
|
* Set current log level
|
|
*
|
|
* @param {LogLevels} logLevel - log level to set
|
|
*/
|
|
export const setLogLevel = (logLevel: LogLevels): void => {
|
|
_log.logLevel = logLevel;
|
|
};
|
|
|
|
/**
|
|
* _log method proxy without Editor.js label
|
|
*
|
|
* @param msg - message to log
|
|
* @param type - console method name
|
|
* @param args - optional payload to pass to console
|
|
* @param style - optional css style for the first argument
|
|
*/
|
|
export const log = (
|
|
msg: string,
|
|
type: ConsoleMethod = 'log',
|
|
args?: unknown,
|
|
style?: string
|
|
): void => {
|
|
_log(false, msg, type, args, style);
|
|
};
|
|
|
|
/**
|
|
* _log method proxy with Editor.js label
|
|
*
|
|
* @param msg - message to log
|
|
* @param type - console method name
|
|
* @param args - optional payload to pass to console
|
|
* @param style - optional css style for the first argument
|
|
*/
|
|
export const logLabeled = (
|
|
msg: string,
|
|
type: ConsoleMethod = 'log',
|
|
args?: unknown,
|
|
style?: string
|
|
): void => {
|
|
_log(true, msg, type, args, style);
|
|
};
|
|
|
|
/**
|
|
* Check if passed variable is a function
|
|
*
|
|
* @param {*} fn - function to check
|
|
* @returns {boolean}
|
|
*/
|
|
export const isFunction = (fn: unknown): fn is (...args: unknown[]) => unknown => {
|
|
return lodashIsFunction(fn);
|
|
};
|
|
|
|
/**
|
|
* Checks if passed argument is an object
|
|
*
|
|
* @param {*} v - object to check
|
|
* @returns {boolean}
|
|
*/
|
|
export const isObject = (v: unknown): v is object => {
|
|
return lodashIsPlainObject(v);
|
|
};
|
|
|
|
/**
|
|
* Checks if passed argument is a string
|
|
*
|
|
* @param {*} v - variable to check
|
|
* @returns {boolean}
|
|
*/
|
|
export const isString = (v: unknown): v is string => {
|
|
return lodashIsString(v);
|
|
};
|
|
|
|
/**
|
|
* Checks if passed argument is boolean
|
|
*
|
|
* @param {*} v - variable to check
|
|
* @returns {boolean}
|
|
*/
|
|
export const isBoolean = (v: unknown): v is boolean => {
|
|
return lodashIsBoolean(v);
|
|
};
|
|
|
|
/**
|
|
* Checks if passed argument is number
|
|
*
|
|
* @param {*} v - variable to check
|
|
* @returns {boolean}
|
|
*/
|
|
export const isNumber = (v: unknown): v is number => {
|
|
return lodashIsNumber(v);
|
|
};
|
|
|
|
/**
|
|
* Checks if passed argument is undefined
|
|
*
|
|
* @param {*} v - variable to check
|
|
* @returns {boolean}
|
|
*/
|
|
export const isUndefined = function (v: unknown): v is undefined {
|
|
return lodashIsUndefined(v);
|
|
};
|
|
|
|
/**
|
|
* Checks if object is empty
|
|
*
|
|
* @param {object} object - object to check
|
|
* @returns {boolean}
|
|
*/
|
|
export const isEmpty = (object: object | null | undefined): boolean => {
|
|
return lodashIsEmpty(object);
|
|
};
|
|
|
|
/**
|
|
* Returns true if passed key code is printable (a-Z, 0-9, etc) character.
|
|
*
|
|
* @param {number} keyCode - key code
|
|
* @returns {boolean}
|
|
*/
|
|
export const isPrintableKey = (keyCode: number): boolean => {
|
|
return (keyCode > keyCodes.NUMBER_KEY_MIN && keyCode < keyCodes.NUMBER_KEY_MAX) || // number keys
|
|
keyCode === keyCodes.SPACE || keyCode === keyCodes.ENTER || // Space bar & return key(s)
|
|
keyCode === keyCodes.PROCESSING_KEY || // processing key input for certain languages — Chinese, Japanese, etc.
|
|
(keyCode > keyCodes.LETTER_KEY_MIN && keyCode < keyCodes.LETTER_KEY_MAX) || // letter keys
|
|
(keyCode > keyCodes.NUMPAD_KEY_MIN && keyCode < keyCodes.NUMPAD_KEY_MAX) || // Numpad keys
|
|
(keyCode > keyCodes.PUNCTUATION_KEY_MIN && keyCode < keyCodes.PUNCTUATION_KEY_MAX) || // ;=,-./` (in order)
|
|
(keyCode > keyCodes.BRACKET_KEY_MIN && keyCode < keyCodes.BRACKET_KEY_MAX); // [\]' (in order)
|
|
};
|
|
|
|
/**
|
|
* Make array from array-like collection
|
|
*
|
|
* @param {ArrayLike} collection - collection to convert to array
|
|
* @returns {Array}
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export const array = (collection: ArrayLike<any>): any[] => {
|
|
return lodashToArray(collection);
|
|
};
|
|
|
|
/**
|
|
* Delays method execution
|
|
*
|
|
* @param {Function} method - method to execute
|
|
* @param {number} timeout - timeout in ms
|
|
*/
|
|
export const delay = (method: (...args: unknown[]) => unknown, timeout: number) => {
|
|
return function (this: unknown, ...args: unknown[]): void {
|
|
void lodashDelay(() => method.apply(this, args), timeout);
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Get file extension
|
|
*
|
|
* @param {File} file - file
|
|
* @returns {string}
|
|
*/
|
|
export const getFileExtension = (file: File): string => {
|
|
return file.name.split('.').pop() ?? '';
|
|
};
|
|
|
|
/**
|
|
* Check if string is MIME type
|
|
*
|
|
* @param {string} type - string to check
|
|
* @returns {boolean}
|
|
*/
|
|
export const isValidMimeType = (type: string): boolean => {
|
|
return /^[-\w]+\/([-+\w]+|\*)$/.test(type);
|
|
};
|
|
|
|
/**
|
|
* Debouncing method
|
|
* Call method after passed time
|
|
*
|
|
* Note that this method returns Function and declared variable need to be called
|
|
*
|
|
* @param {Function} func - function that we're throttling
|
|
* @param {number} wait - time in milliseconds
|
|
* @param {boolean} immediate - call now
|
|
* @returns {Function}
|
|
*/
|
|
export const debounce = (func: (...args: unknown[]) => void, wait?: number, immediate?: boolean): (...args: unknown[]) => void => {
|
|
const state = {
|
|
timeoutId: null as ReturnType<typeof setTimeout> | null,
|
|
};
|
|
|
|
return function (this: unknown, ...args: unknown[]): void {
|
|
const later = (): void => {
|
|
state.timeoutId = null;
|
|
if (immediate !== true) {
|
|
func.apply(this, args);
|
|
}
|
|
};
|
|
|
|
const callNow = immediate === true && state.timeoutId === null;
|
|
|
|
if (state.timeoutId !== null) {
|
|
clearTimeout(state.timeoutId);
|
|
}
|
|
state.timeoutId = setTimeout(later, wait);
|
|
if (callNow) {
|
|
func.apply(this, args);
|
|
}
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Returns a function, that, when invoked, will only be triggered at most once during a given window of time.
|
|
*
|
|
* @param func - function to throttle
|
|
* @param wait - function will be called only once for that period
|
|
* @param options - Normally, the throttled function will run as much as it can
|
|
* without ever going more than once per `wait` duration;
|
|
* but if you'd like to disable the execution on the leading edge, pass
|
|
* `{leading: false}`. To disable execution on the trailing edge, ditto.
|
|
*/
|
|
export const throttle = (
|
|
func: (...args: unknown[]) => unknown,
|
|
wait: number,
|
|
options?: {leading?: boolean; trailing?: boolean}
|
|
): ((...args: unknown[]) => unknown) => {
|
|
return lodashThrottle(func, wait, options);
|
|
};
|
|
|
|
/**
|
|
* Returns object with os name as key and boolean as value. Shows current user OS
|
|
*/
|
|
export const getUserOS = (): {[key: string]: boolean} => {
|
|
const OS: {[key: string]: boolean} = {
|
|
win: false,
|
|
mac: false,
|
|
x11: false,
|
|
linux: false,
|
|
};
|
|
|
|
const navigatorRef = getGlobalNavigator();
|
|
const userAgent = navigatorRef?.userAgent?.toLowerCase() ?? '';
|
|
const userOS = userAgent ? Object.keys(OS).find((os: string) => userAgent.indexOf(os) !== -1) : undefined;
|
|
|
|
if (userOS !== undefined) {
|
|
OS[userOS] = true;
|
|
|
|
return OS;
|
|
}
|
|
|
|
return OS;
|
|
};
|
|
|
|
/**
|
|
* Capitalizes first letter of the string
|
|
*
|
|
* @param {string} text - text to capitalize
|
|
* @returns {string}
|
|
*/
|
|
export const capitalize = (text: string): string => {
|
|
if (!text) {
|
|
return text;
|
|
}
|
|
|
|
return text.slice(0, 1).toUpperCase() + text.slice(1);
|
|
};
|
|
|
|
/**
|
|
* Customizer function for deep merge that overwrites arrays
|
|
*
|
|
* @param {unknown} objValue - object value
|
|
* @param {unknown} srcValue - source value
|
|
* @returns {unknown}
|
|
*/
|
|
const overwriteArrayMerge = (objValue: unknown, srcValue: unknown): unknown => {
|
|
if (Array.isArray(srcValue)) {
|
|
return srcValue;
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
export const deepMerge = <T extends object> (target: T, ...sources: Partial<T>[]): T => {
|
|
if (!isObject(target) || sources.length === 0) {
|
|
return target;
|
|
}
|
|
|
|
return sources.reduce((acc: T, source) => {
|
|
if (!isObject(source)) {
|
|
return acc;
|
|
}
|
|
|
|
return lodashMergeWith(acc, source, overwriteArrayMerge) as T;
|
|
}, target);
|
|
};
|
|
|
|
/**
|
|
* Make shortcut command more human-readable
|
|
*
|
|
* @param {string} shortcut — string like 'CMD+B'
|
|
*/
|
|
export const beautifyShortcut = (shortcut: string): string => {
|
|
const OS = getUserOS();
|
|
const normalizedShortcut = shortcut
|
|
.replace(/shift/gi, '⇧')
|
|
.replace(/backspace/gi, '⌫')
|
|
.replace(/enter/gi, '⏎')
|
|
.replace(/up/gi, '↑')
|
|
.replace(/left/gi, '→')
|
|
.replace(/down/gi, '↓')
|
|
.replace(/right/gi, '←')
|
|
.replace(/escape/gi, '⎋')
|
|
.replace(/insert/gi, 'Ins')
|
|
.replace(/delete/gi, '␡')
|
|
.replace(/\+/gi, ' + ');
|
|
|
|
if (OS.mac) {
|
|
return normalizedShortcut.replace(/ctrl|cmd/gi, '⌘').replace(/alt/gi, '⌥');
|
|
}
|
|
|
|
return normalizedShortcut.replace(/cmd/gi, 'Ctrl').replace(/windows/gi, 'WIN');
|
|
};
|
|
|
|
/**
|
|
* Returns valid URL. If it is going outside and valid, it returns itself
|
|
* If url has `one slash`, then it concatenates with window location origin
|
|
* or when url has `two lack` it appends only protocol
|
|
*
|
|
* @param {string} url - url to prettify
|
|
*/
|
|
export const getValidUrl = (url: string): string => {
|
|
try {
|
|
const urlObject = new URL(url);
|
|
|
|
return urlObject.href;
|
|
} catch (e) {
|
|
// do nothing but handle below
|
|
}
|
|
|
|
const win = getGlobalWindow();
|
|
|
|
if (url.substring(0, 2) === '//') {
|
|
return win ? `${win.location.protocol}${url}` : url;
|
|
}
|
|
|
|
return win ? `${win.location.origin}${url}` : url;
|
|
};
|
|
|
|
/**
|
|
* Create a block id
|
|
*
|
|
* @returns {string}
|
|
*/
|
|
export const generateBlockId = (): string => {
|
|
const idLen = 10;
|
|
|
|
return nanoid(idLen);
|
|
};
|
|
|
|
/**
|
|
* Opens new Tab with passed URL
|
|
*
|
|
* @param {string} url - URL address to redirect
|
|
*/
|
|
export const openTab = (url: string): void => {
|
|
const win = getGlobalWindow();
|
|
|
|
if (!win) {
|
|
return;
|
|
}
|
|
|
|
win.open(url, '_blank');
|
|
};
|
|
|
|
/**
|
|
* Returns random generated identifier
|
|
*
|
|
* @param {string} prefix - identifier prefix
|
|
* @returns {string}
|
|
*/
|
|
export const generateId = (prefix = ''): string => {
|
|
return `${prefix}${(Math.floor(Math.random() * ID_RANDOM_MULTIPLIER)).toString(HEXADECIMAL_RADIX)}`;
|
|
};
|
|
|
|
|
|
type CacheableAccessor<Value> = {
|
|
get?: () => Value;
|
|
set?: (value: Value) => void;
|
|
init?: (value: Value) => Value;
|
|
};
|
|
|
|
type Stage3DecoratorContext = {
|
|
kind: 'method' | 'getter' | 'setter' | 'accessor';
|
|
name: string | symbol;
|
|
static?: boolean;
|
|
private?: boolean;
|
|
access?: {
|
|
get?: () => unknown;
|
|
set?: (value: unknown) => void;
|
|
};
|
|
};
|
|
|
|
type CacheableDecorator = {
|
|
<Member>(
|
|
target: object,
|
|
propertyKey: string | symbol,
|
|
descriptor?: TypedPropertyDescriptor<Member>
|
|
): TypedPropertyDescriptor<Member> | void;
|
|
<Value = unknown, Arguments extends unknown[] = unknown[]>(
|
|
value: ((...args: Arguments) => Value) | CacheableAccessor<Value>,
|
|
context: Stage3DecoratorContext
|
|
):
|
|
| ((...args: Arguments) => Value)
|
|
| CacheableAccessor<Value>;
|
|
};
|
|
|
|
const ensureCacheValue = <Value>(
|
|
holder: object,
|
|
cacheKey: string | symbol,
|
|
compute: () => Value
|
|
): Value => {
|
|
if (!Reflect.has(holder, cacheKey)) {
|
|
Object.defineProperty(holder, cacheKey, {
|
|
configurable: true,
|
|
writable: true,
|
|
value: compute(),
|
|
});
|
|
}
|
|
|
|
return Reflect.get(holder, cacheKey) as Value;
|
|
};
|
|
|
|
const clearCacheValue = (holder: object, cacheKey: string | symbol): void => {
|
|
if (Reflect.has(holder, cacheKey)) {
|
|
Reflect.deleteProperty(holder, cacheKey);
|
|
}
|
|
};
|
|
|
|
const isStage3DecoratorContext = (context: unknown): context is Stage3DecoratorContext => {
|
|
if (typeof context !== 'object' || context === null) {
|
|
return false;
|
|
}
|
|
|
|
return 'kind' in context && 'name' in context;
|
|
};
|
|
|
|
const buildLegacyCacheableDescriptor = (
|
|
target: Record<PropertyKey, unknown>,
|
|
propertyKey: string | symbol,
|
|
descriptor?: TypedPropertyDescriptor<unknown>
|
|
): TypedPropertyDescriptor<unknown> => {
|
|
const baseDescriptor =
|
|
descriptor ??
|
|
Object.getOwnPropertyDescriptor(target, propertyKey) ??
|
|
(typeof target === 'function'
|
|
? Object.getOwnPropertyDescriptor((target as unknown as { prototype?: object }).prototype ?? {}, propertyKey)
|
|
: undefined) ??
|
|
{
|
|
configurable: true,
|
|
enumerable: false,
|
|
writable: true,
|
|
value: Reflect.get(target, propertyKey),
|
|
};
|
|
|
|
const descriptorRef = { ...baseDescriptor } as TypedPropertyDescriptor<unknown>;
|
|
const cacheKey: string | symbol = typeof propertyKey === 'symbol' ? propertyKey : `#${propertyKey}Cache`;
|
|
const hasMethodValue = descriptorRef.value !== undefined && typeof descriptorRef.value === 'function';
|
|
const shouldWrapGetter = !hasMethodValue && descriptorRef.get !== undefined;
|
|
const shouldWrapSetter = shouldWrapGetter && descriptorRef.set !== undefined;
|
|
|
|
if (hasMethodValue) {
|
|
const originalMethod = descriptorRef.value as (...methodArgs: unknown[]) => unknown;
|
|
|
|
descriptorRef.value = function (this: object, ...methodArgs: unknown[]): unknown {
|
|
return ensureCacheValue(this, cacheKey, () => originalMethod.apply(this, methodArgs));
|
|
} as typeof originalMethod;
|
|
}
|
|
|
|
if (shouldWrapGetter && descriptorRef.get !== undefined) {
|
|
const originalGetter = descriptorRef.get as () => unknown;
|
|
|
|
descriptorRef.get = function (this: object): unknown {
|
|
return ensureCacheValue(this, cacheKey, () => originalGetter.call(this));
|
|
} as typeof originalGetter;
|
|
}
|
|
|
|
if (shouldWrapSetter && descriptorRef.set !== undefined) {
|
|
const originalSetter = descriptorRef.set;
|
|
|
|
descriptorRef.set = function (this: object, newValue: unknown): void {
|
|
clearCacheValue(this, cacheKey);
|
|
originalSetter.call(this, newValue);
|
|
} as typeof originalSetter;
|
|
}
|
|
|
|
if (!descriptor) {
|
|
return descriptorRef;
|
|
}
|
|
|
|
Object.keys(descriptor).forEach(propertyName => {
|
|
if (!(propertyName in descriptorRef)) {
|
|
Reflect.deleteProperty(descriptor, propertyName as keyof PropertyDescriptor);
|
|
}
|
|
});
|
|
|
|
Object.assign(descriptor, descriptorRef);
|
|
|
|
return descriptor;
|
|
};
|
|
|
|
const applyStage3CacheableDecorator = (
|
|
value: ((...methodArgs: unknown[]) => unknown) | CacheableAccessor<unknown>,
|
|
context: Stage3DecoratorContext
|
|
): unknown => {
|
|
const cacheKey = Symbol(
|
|
typeof context.name === 'symbol'
|
|
? `cache:${context.name.description ?? 'symbol'}`
|
|
: `cache:${context.name}`
|
|
);
|
|
|
|
if (context.kind === 'method' && typeof value === 'function') {
|
|
const originalMethod = value as (...methodArgs: unknown[]) => unknown;
|
|
|
|
return function (this: object, ...methodArgs: unknown[]): unknown {
|
|
return ensureCacheValue(this, cacheKey, () => originalMethod.apply(this, methodArgs));
|
|
} as typeof originalMethod;
|
|
}
|
|
|
|
if (context.kind === 'getter' && typeof value === 'function') {
|
|
const originalGetter = value as () => unknown;
|
|
|
|
return function (this: object): unknown {
|
|
return ensureCacheValue(this, cacheKey, () => originalGetter.call(this));
|
|
} as typeof originalGetter;
|
|
}
|
|
|
|
if (context.kind === 'accessor' && typeof value === 'object' && value !== null) {
|
|
const accessor = value as CacheableAccessor<unknown>;
|
|
const fallbackGetter = accessor.get ?? context.access?.get;
|
|
const fallbackSetter = accessor.set ?? context.access?.set;
|
|
|
|
return {
|
|
get(this: object): unknown {
|
|
return fallbackGetter
|
|
? ensureCacheValue(this, cacheKey, () => fallbackGetter.call(this))
|
|
: undefined;
|
|
},
|
|
set(this: object, newValue: unknown): void {
|
|
clearCacheValue(this, cacheKey);
|
|
fallbackSetter?.call(this, newValue);
|
|
},
|
|
init(initialValue: unknown): unknown {
|
|
return accessor.init ? accessor.init(initialValue) : initialValue;
|
|
},
|
|
} satisfies CacheableAccessor<unknown>;
|
|
}
|
|
|
|
return value;
|
|
};
|
|
|
|
/**
|
|
* Decorator which provides ability to cache method or accessor result.
|
|
* Supports both legacy and TC39 stage 3 decorator semantics.
|
|
*
|
|
* @param args - decorator arguments (legacy: target, propertyKey, descriptor. Stage 3: value, context)
|
|
*/
|
|
const cacheableImpl = (...args: unknown[]): unknown => {
|
|
if (args.length === 2 && isStage3DecoratorContext(args[1])) {
|
|
const [value, context] = args as [
|
|
((...methodArgs: unknown[]) => unknown) | CacheableAccessor<unknown>,
|
|
Stage3DecoratorContext
|
|
];
|
|
|
|
return applyStage3CacheableDecorator(value, context);
|
|
}
|
|
|
|
const [target, propertyKey, descriptor] = args as [
|
|
Record<PropertyKey, unknown>,
|
|
string | symbol,
|
|
TypedPropertyDescriptor<unknown> | undefined
|
|
];
|
|
|
|
return buildLegacyCacheableDescriptor(target, propertyKey, descriptor);
|
|
};
|
|
|
|
export const cacheable = cacheableImpl as CacheableDecorator;
|
|
|
|
/**
|
|
* All screens below this width will be treated as mobile;
|
|
*/
|
|
export const mobileScreenBreakpoint = 650;
|
|
|
|
/**
|
|
* True if screen has mobile size
|
|
*/
|
|
export const isMobileScreen = (): boolean => {
|
|
const win = getGlobalWindow();
|
|
|
|
if (!win || typeof win.matchMedia !== 'function') {
|
|
return false;
|
|
}
|
|
|
|
return win.matchMedia(`(max-width: ${mobileScreenBreakpoint}px)`).matches;
|
|
};
|
|
|
|
/**
|
|
* True if current device runs iOS
|
|
*/
|
|
export const isIosDevice = (() => {
|
|
const navigatorRef = getGlobalNavigator();
|
|
|
|
if (!navigatorRef) {
|
|
return false;
|
|
}
|
|
|
|
const userAgent = navigatorRef.userAgent || '';
|
|
|
|
// Use modern User-Agent Client Hints API if available
|
|
const userAgentData = (navigatorRef as Navigator & { userAgentData?: { platform?: string } }).userAgentData;
|
|
const platform = userAgentData?.platform;
|
|
|
|
// Check userAgent string first (most reliable method)
|
|
if (/iP(ad|hone|od)/.test(userAgent)) {
|
|
return true;
|
|
}
|
|
|
|
// Check platform from User-Agent Client Hints API if available
|
|
if (platform !== undefined && platform !== '' && /iP(ad|hone|od)/.test(platform)) {
|
|
return true;
|
|
}
|
|
|
|
// Check for iPad on iOS 13+ (reports as MacIntel with touch support)
|
|
// Only access deprecated platform property when necessary
|
|
const hasTouchSupport = (navigatorRef.maxTouchPoints ?? 0) > 1;
|
|
const getLegacyPlatform = (): string | undefined =>
|
|
(navigatorRef as Navigator & { platform?: string })['platform'];
|
|
const platformHint = platform !== undefined && platform !== '' ? platform : undefined;
|
|
const platformValue = hasTouchSupport ? platformHint ?? getLegacyPlatform() : undefined;
|
|
|
|
if (platformValue === 'MacIntel') {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
})();
|
|
|
|
/**
|
|
* Compares two values with unknown type
|
|
*
|
|
* @param var1 - value to compare
|
|
* @param var2 - value to compare with
|
|
* @returns {boolean} true if they are equal
|
|
*/
|
|
export const equals = (var1: unknown, var2: unknown): boolean => {
|
|
return lodashIsEqual(var1, var2);
|
|
};
|