Move "core" folder out of "features" into "base"
This commit is contained in:
15
src/base/utils/ApplyStyles.ts
Normal file
15
src/base/utils/ApplyStyles.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Theme } from '@mui/material/styles';
|
||||
|
||||
// use CSSObject instead of SxProps<Theme> because this completely fucks over typescript by causing an out of memory error during compilation
|
||||
type CSSObject = ReturnType<Theme['applyStyles']>;
|
||||
|
||||
const emptyStyle = {};
|
||||
export const applyStyles = (isActive: boolean, styling: CSSObject): CSSObject => (isActive ? styling : emptyStyle);
|
||||
50
src/base/utils/AwaitableDialog.tsx
Normal file
50
src/base/utils/AwaitableDialog.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { ConfirmDialog } from '@/base/components/modals/ConfirmDialog.tsx';
|
||||
import { ControlledPromise } from '@/lib/ControlledPromise.ts';
|
||||
import { getCurrentTheme } from '@/features/theme/services/ThemeCreator.ts';
|
||||
|
||||
export const awaitConfirmation = async (
|
||||
dialogProps: Omit<React.ComponentProps<typeof ConfirmDialog>, 'onCancel' | 'onConfirm'>,
|
||||
) => {
|
||||
const dialogContainer = document.createElement('div');
|
||||
document.body.appendChild(dialogContainer);
|
||||
|
||||
const root = createRoot(dialogContainer);
|
||||
|
||||
const confirmationPromise = new ControlledPromise();
|
||||
const handleConfirmation = (accepted: boolean) => {
|
||||
if (accepted) {
|
||||
confirmationPromise.resolve();
|
||||
} else {
|
||||
confirmationPromise.reject(new Error('Confirmation declined'));
|
||||
}
|
||||
|
||||
root.unmount();
|
||||
document.body.removeChild(dialogContainer);
|
||||
};
|
||||
|
||||
root.render(
|
||||
<ThemeProvider theme={getCurrentTheme()}>
|
||||
<ConfirmDialog
|
||||
{...dialogProps}
|
||||
onExtra={() => {
|
||||
handleConfirmation(false);
|
||||
dialogProps.onExtra?.();
|
||||
}}
|
||||
onCancel={() => handleConfirmation(false)}
|
||||
onConfirm={() => handleConfirmation(true)}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
return confirmationPromise.promise;
|
||||
};
|
||||
71
src/base/utils/DateHelper.ts
Normal file
71
src/base/utils/DateHelper.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { t } from 'i18next';
|
||||
|
||||
export const timeFormatter = new Intl.DateTimeFormat(navigator.language, { hour: '2-digit', minute: '2-digit' });
|
||||
export const dateFormatter = new Intl.DateTimeFormat(navigator.language, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
export const dateTimeFormatter = new Intl.DateTimeFormat(navigator.language, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
export const epochToDate = (epoch: number): Dayjs => dayjs.unix(epoch);
|
||||
|
||||
export const isSameDay = (first: Dayjs, second: Dayjs): boolean => first.isSame(second, 'day');
|
||||
|
||||
/**
|
||||
* Returns a string in localized format for the passed date.
|
||||
*
|
||||
* In case the date is from today or yesterday a special string will be returned including "Today"
|
||||
* or "Yesterday".
|
||||
* Optionally this special string can include the localized time of the passed date ("Today/Yesterday at HH:mm").
|
||||
*
|
||||
* @example
|
||||
* const today = dayjs();
|
||||
* const yesterday = today.subtract(1, 'day');
|
||||
* const someDate = dayjs('1377-04-20');
|
||||
*
|
||||
* const todayAsString = getDateString(today); // => "Today"
|
||||
* const yesterdayAsString = getDateString(yesterday, true) // => "Yesterday at 02:50 AM"
|
||||
* const someDate = getDateString(someDate) // => "04/20/1337"
|
||||
*
|
||||
*
|
||||
* @param date
|
||||
* @param withTime
|
||||
*/
|
||||
export const getDateString = (date: Dayjs | number, withTime: boolean = false) => {
|
||||
const actualDate = date instanceof dayjs ? date : dayjs(date);
|
||||
const timeString = timeFormatter.format(actualDate.toDate());
|
||||
|
||||
if (actualDate.isToday()) {
|
||||
if (withTime) {
|
||||
return t('global.date.label.today_at', { timeString });
|
||||
}
|
||||
|
||||
return t('global.date.label.today');
|
||||
}
|
||||
|
||||
if (actualDate.isYesterday()) {
|
||||
if (withTime) {
|
||||
return t('global.date.label.yesterday_at', { timeString });
|
||||
}
|
||||
|
||||
return t('global.date.label.yesterday');
|
||||
}
|
||||
|
||||
return dateFormatter.format(actualDate.toDate());
|
||||
};
|
||||
145
src/base/utils/Languages.ts
Normal file
145
src/base/utils/Languages.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { t } from 'i18next';
|
||||
import { ISOLanguage, IsoLanguages } from '@/base/IsoLanguages.ts';
|
||||
|
||||
import { TranslationKey } from '@/base/Base.types.ts';
|
||||
|
||||
export enum DefaultLanguage {
|
||||
ALL = 'all',
|
||||
OTHER = 'other',
|
||||
LOCAL_SOURCE = 'localsourcelang',
|
||||
PINNED = 'pinned',
|
||||
LAST_USED_SOURCE = 'last_used_source',
|
||||
}
|
||||
|
||||
const DEFAULT_LANGUAGE_TO_TRANSLATION: Record<DefaultLanguage, TranslationKey> = {
|
||||
[DefaultLanguage.ALL]: 'extension.language.all',
|
||||
[DefaultLanguage.OTHER]: 'extension.language.other',
|
||||
[DefaultLanguage.LOCAL_SOURCE]: 'extension.language.other',
|
||||
[DefaultLanguage.PINNED]: 'global.label.pinned',
|
||||
[DefaultLanguage.LAST_USED_SOURCE]: 'global.label.last_used',
|
||||
};
|
||||
|
||||
type LanguageObject = ISOLanguage & { orgCode: string; isoCode: string };
|
||||
|
||||
function getISOLanguage(code: string): LanguageObject | null {
|
||||
if (IsoLanguages[code]) {
|
||||
return {
|
||||
...IsoLanguages[code],
|
||||
orgCode: code,
|
||||
isoCode: code,
|
||||
};
|
||||
}
|
||||
|
||||
if (IsoLanguages[code.toLocaleLowerCase()]) {
|
||||
return {
|
||||
...IsoLanguages[code.toLocaleLowerCase()],
|
||||
orgCode: code,
|
||||
isoCode: code.toLocaleLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
const whereToCut = code.indexOf('-') !== -1 ? code.indexOf('-') : code.length;
|
||||
const processedCode = code.toLocaleLowerCase().substring(0, whereToCut);
|
||||
if (IsoLanguages[processedCode]) {
|
||||
return {
|
||||
...IsoLanguages[processedCode],
|
||||
orgCode: code,
|
||||
isoCode: processedCode,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getLanguage(code: string): LanguageObject {
|
||||
const isoLanguage = getISOLanguage(code);
|
||||
|
||||
if (isoLanguage) {
|
||||
return isoLanguage;
|
||||
}
|
||||
|
||||
return {
|
||||
orgCode: code,
|
||||
isoCode: code,
|
||||
name: t('global.language.label.language_with_code', { code }),
|
||||
nativeName: t('global.language.label.language_with_code', { code }),
|
||||
};
|
||||
}
|
||||
|
||||
export function languageCodeToName(code: string): string {
|
||||
const isCustomLanguage = Object.keys(DEFAULT_LANGUAGE_TO_TRANSLATION).includes(code);
|
||||
if (isCustomLanguage) {
|
||||
return t(DEFAULT_LANGUAGE_TO_TRANSLATION[code as DefaultLanguage]);
|
||||
}
|
||||
|
||||
return getLanguage(code).nativeName;
|
||||
}
|
||||
|
||||
export const toUniqueLanguageCodes = (codes: string[]): string[] => {
|
||||
const languages = codes.map((code) => getLanguage(code));
|
||||
const languagesByIsoCode = Object.groupBy(languages, (language) => language.isoCode);
|
||||
|
||||
return Object.entries(languagesByIsoCode)
|
||||
.filter(([, languagesOfIsoCode]) => !!languagesOfIsoCode?.length)
|
||||
.map(([, languagesOfIsoCode]) => languagesOfIsoCode![0].orgCode);
|
||||
};
|
||||
|
||||
export const toComparableLanguage = (code: string): string => getLanguage(code).isoCode;
|
||||
|
||||
export const toComparableLanguages = (codes: string[]): string[] => codes.map(toComparableLanguage);
|
||||
|
||||
function defaultNativeLang(): readonly string[] {
|
||||
const preferredLanguages = toUniqueLanguageCodes([...navigator.languages]);
|
||||
|
||||
if (!preferredLanguages.length) {
|
||||
return ['en'];
|
||||
}
|
||||
|
||||
return preferredLanguages;
|
||||
}
|
||||
|
||||
export function getDefaultLanguages(): string[] {
|
||||
return [...defaultNativeLang(), DefaultLanguage.ALL];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort languages by their native name.
|
||||
* Custom languages are optionally treated specially:
|
||||
* - All: first
|
||||
* - Other: last
|
||||
*/
|
||||
export const languageSortComparator = (a: string, b: string, specialCustomLanguagesHandling?: boolean) => {
|
||||
const isALanguageAll = a === DefaultLanguage.ALL;
|
||||
const isALanguageOther = a === DefaultLanguage.OTHER || a === DefaultLanguage.LOCAL_SOURCE;
|
||||
|
||||
const isBLanguageAll = b === DefaultLanguage.ALL;
|
||||
const isBLanguageOther = b === DefaultLanguage.OTHER || b === DefaultLanguage.LOCAL_SOURCE;
|
||||
|
||||
if (specialCustomLanguagesHandling) {
|
||||
if (isALanguageAll || isBLanguageOther) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (isALanguageOther || isBLanguageAll) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return languageCodeToName(a).localeCompare(languageCodeToName(b));
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort languages by their native name.
|
||||
* Custom languages are treated specially:
|
||||
* - All: first
|
||||
* - Other: last
|
||||
*/
|
||||
export const languageSpecialSortComparator = (a: string, b: string) => languageSortComparator(a, b, true);
|
||||
11
src/base/utils/LazyLoad.tsx
Normal file
11
src/base/utils/LazyLoad.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
|
||||
|
||||
export const lazyLoadFallback = { fallback: <LoadingPlaceholder /> };
|
||||
132
src/base/utils/MediaQuery.tsx
Normal file
132
src/base/utils/MediaQuery.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { Breakpoint, SxProps, Theme } from '@mui/material/styles';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { getCurrentTheme } from '@/features/theme/services/ThemeCreator.ts';
|
||||
import { ThemeMode } from '@/features/theme/AppThemeContext.tsx';
|
||||
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
|
||||
|
||||
export class MediaQuery {
|
||||
static readonly MOBILE_WIDTH: Breakpoint | number = 'sm';
|
||||
|
||||
static readonly TABLET_WIDTH: Breakpoint | number = 1025;
|
||||
|
||||
static isTouchDevice(): boolean {
|
||||
return window.matchMedia('not (pointer: fine)').matches;
|
||||
}
|
||||
|
||||
static useIsTouchDevice(): boolean {
|
||||
return useMediaQuery('not (pointer: fine)');
|
||||
}
|
||||
|
||||
static useIsBelowWidth(breakpoint: Breakpoint | number): boolean {
|
||||
return useMediaQuery(getCurrentTheme().breakpoints.down(breakpoint));
|
||||
}
|
||||
|
||||
static useIsMobileWidth(): boolean {
|
||||
return this.useIsBelowWidth(this.MOBILE_WIDTH);
|
||||
}
|
||||
|
||||
static useIsTabletWidth(): boolean {
|
||||
return this.useIsBelowWidth(this.TABLET_WIDTH);
|
||||
}
|
||||
|
||||
private static getScrollbarSize(type: 'height' | 'width'): number {
|
||||
const outer = document.createElement('div');
|
||||
outer.style.position = 'absolute';
|
||||
outer.style.top = '-9999px';
|
||||
outer.style.visibility = 'hidden';
|
||||
outer.style.overflow = 'scroll';
|
||||
document.body.appendChild(outer);
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.style.width = '100%';
|
||||
inner.style.height = '100%';
|
||||
outer.appendChild(inner);
|
||||
|
||||
const width = outer.offsetWidth - inner.offsetWidth;
|
||||
const height = outer.offsetHeight - inner.offsetHeight;
|
||||
|
||||
document.body.removeChild(outer);
|
||||
|
||||
return type === 'height' ? height : width;
|
||||
}
|
||||
|
||||
static useGetScrollbarSize(
|
||||
type: 'height' | 'width',
|
||||
element: HTMLElement | null = document.documentElement,
|
||||
): number {
|
||||
const [scrollbarSize, setScrollbarSize] = useState(0);
|
||||
|
||||
useResizeObserver(
|
||||
element,
|
||||
useCallback(() => {
|
||||
const hasYScrollbar = !!(element!.scrollHeight - element!.clientHeight);
|
||||
const hasXScrollbar = !!(element!.scrollWidth - element!.clientWidth);
|
||||
|
||||
const hasScrollbar = (type === 'height' && hasYScrollbar) || (type === 'width' && hasXScrollbar);
|
||||
if (hasScrollbar) {
|
||||
setScrollbarSize(this.getScrollbarSize(type));
|
||||
return;
|
||||
}
|
||||
|
||||
setScrollbarSize(0);
|
||||
}, [element]),
|
||||
);
|
||||
|
||||
return scrollbarSize;
|
||||
}
|
||||
|
||||
static getSystemThemeMode(): Exclude<ThemeMode, 'system'> {
|
||||
const prefersDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
return prefersDarkMode ? ThemeMode.DARK : ThemeMode.LIGHT;
|
||||
}
|
||||
|
||||
static getThemeMode(themeMode: ThemeMode): Exclude<ThemeMode, 'system'> {
|
||||
const isSystemMode = themeMode === ThemeMode.SYSTEM;
|
||||
if (isSystemMode) {
|
||||
return this.getSystemThemeMode();
|
||||
}
|
||||
|
||||
return themeMode;
|
||||
}
|
||||
|
||||
static listenToSystemThemeChange(onChange: (themeMode: Exclude<ThemeMode, 'system'>) => void): () => void {
|
||||
const handleSystemThemeModeChange = (e: MediaQueryListEvent) => {
|
||||
onChange(e.matches ? ThemeMode.DARK : ThemeMode.LIGHT);
|
||||
};
|
||||
|
||||
const matchSystemThemeMode = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
matchSystemThemeMode.addEventListener('change', handleSystemThemeModeChange);
|
||||
|
||||
return () => matchSystemThemeMode.removeEventListener('change', handleSystemThemeModeChange);
|
||||
}
|
||||
|
||||
static usePreventMobileContextMenu() {
|
||||
const isTouchDevice = MediaQuery.useIsTouchDevice();
|
||||
|
||||
return useCallback(
|
||||
(e: React.MouseEvent<any, MouseEvent>) => {
|
||||
if (isTouchDevice) {
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[isTouchDevice],
|
||||
);
|
||||
}
|
||||
|
||||
static preventMobileContextMenuSx(): SxProps<Theme> {
|
||||
return {
|
||||
userSelect: 'none',
|
||||
'-webkit-touch-callout': 'none',
|
||||
};
|
||||
}
|
||||
}
|
||||
14
src/base/utils/ShouldForwardProp.ts
Normal file
14
src/base/utils/ShouldForwardProp.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export const shouldForwardProp =
|
||||
<TCustomProps extends Record<string, unknown>>(customProps: TupleUnion<keyof TCustomProps>) =>
|
||||
(prop: string): boolean =>
|
||||
// @ts-ignore - TS2589: Type instantiation is excessively deep and possibly infinite.
|
||||
// this function should never be used without a strict type, thus, this error can be ignored
|
||||
!customProps.includes(prop);
|
||||
18
src/base/utils/Strings.ts
Normal file
18
src/base/utils/Strings.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export const baseCleanup = (str: string) => str.toLowerCase().trim();
|
||||
|
||||
export const enhancedCleanup = (str: string): string =>
|
||||
baseCleanup(str)
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim();
|
||||
|
||||
export const reverseString = (str: string, separator: string = ''): string =>
|
||||
str.split(separator).reverse().join(separator);
|
||||
28
src/base/utils/Toast.ts
Normal file
28
src/base/utils/Toast.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { enqueueSnackbar, OptionsObject, SnackbarKey } from 'notistack';
|
||||
|
||||
export function makeToast(message: string, severity?: OptionsObject['variant'], description?: string): SnackbarKey;
|
||||
export function makeToast(message: string, options?: OptionsObject, description?: string): SnackbarKey;
|
||||
export function makeToast(
|
||||
message: string,
|
||||
options: OptionsObject['variant'] | OptionsObject = 'default',
|
||||
description?: string,
|
||||
): SnackbarKey {
|
||||
const variant = typeof options === 'string' ? options : undefined;
|
||||
const snackbarOptions = typeof options === 'object' ? options : {};
|
||||
|
||||
return enqueueSnackbar(message, {
|
||||
variant,
|
||||
...snackbarOptions,
|
||||
// @ts-ignore - TS2353, "notistack" is outdated and the provided way to define custom props is not working, however,
|
||||
// everything in the options object gets passed to the custom snackbar component
|
||||
description,
|
||||
});
|
||||
}
|
||||
23
src/base/utils/ValueRotationButton.utils.ts
Normal file
23
src/base/utils/ValueRotationButton.utils.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export const getNextRotationValue = <Value>(
|
||||
indexOfValue: number,
|
||||
values: Value[],
|
||||
isDefaultable?: boolean,
|
||||
): Value | undefined => {
|
||||
const nextValueIndex = (indexOfValue + 1) % values.length;
|
||||
const wasLastValue = nextValueIndex === 0;
|
||||
|
||||
const isDefaultNextValue = !!isDefaultable && wasLastValue;
|
||||
if (isDefaultNextValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return values[(indexOfValue + 1) % values.length];
|
||||
};
|
||||
11
src/base/utils/cloneObject.tsx
Normal file
11
src/base/utils/cloneObject.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export function cloneObject<T extends object>(obj: T) {
|
||||
return JSON.parse(JSON.stringify(obj)) as T;
|
||||
}
|
||||
Reference in New Issue
Block a user