mirror of
https://github.com/ant-design/ant-design.git
synced 2025-06-07 09:26:06 +08:00
commit
f34ff52cec
172
.dumi/mirror-modal.js
Normal file
172
.dumi/mirror-modal.js
Normal file
@ -0,0 +1,172 @@
|
||||
(function createMirrorModal() {
|
||||
if (
|
||||
(navigator.languages.includes('zh') || navigator.languages.includes('zh-CN')) &&
|
||||
/-cn\/?$/.test(window.location.pathname) &&
|
||||
!['ant-design.gitee.io', 'ant-design.antgroup.com'].includes(window.location.hostname)
|
||||
) {
|
||||
const ANTD_DOT_NOT_SHOW_MIRROR_MODAL = 'ANT_DESIGN_DO_NOT_OPEN_MIRROR_MODAL';
|
||||
|
||||
const lastShowTime = window.localStorage.getItem(ANTD_DOT_NOT_SHOW_MIRROR_MODAL);
|
||||
if (
|
||||
lastShowTime &&
|
||||
lastShowTime !== 'true' &&
|
||||
Date.now() - new Date(lastShowTime).getTime() < 7 * 24 * 60 * 60 * 1000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `
|
||||
@keyframes mirror-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mirror-zoom-in {
|
||||
from {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.mirror-modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
height: '100vh';
|
||||
width: '100vw';
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 9999;
|
||||
animation: mirror-fade-in 0.3s forwards;
|
||||
}
|
||||
|
||||
.mirror-modal-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: 120px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 400px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #eee;
|
||||
background: #fff;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
animation: mirror-zoom-in 0.3s forwards;
|
||||
}
|
||||
|
||||
.mirror-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
align-self: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mirror-modal-content {
|
||||
font-size: 14px;
|
||||
align-self: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mirror-modal-btns {
|
||||
align-self: flex-end;
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mirror-modal-btn {
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
padding: 4px 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mirror-modal-confirm-btn {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mirror-modal-confirm-btn:hover {
|
||||
background: #4096ff;
|
||||
}
|
||||
|
||||
.mirror-modal-confirm-btn:active {
|
||||
background: #0958d9;
|
||||
}
|
||||
|
||||
.mirror-modal-cancel-btn {
|
||||
border: 1px solid #eee;
|
||||
color: #000;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.mirror-modal-cancel-btn:hover {
|
||||
border-color: #4096ff;
|
||||
color: #4096ff
|
||||
}
|
||||
|
||||
.mirror-modal-cancel-btn:active {
|
||||
border-color: #0958d9;
|
||||
color: #0958d9;
|
||||
}
|
||||
`;
|
||||
document.head.append(style);
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'mirror-modal-mask';
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'mirror-modal-dialog';
|
||||
modal.append(dialog);
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'mirror-modal-title';
|
||||
title.innerText = '提示';
|
||||
dialog.append(title);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'mirror-modal-content';
|
||||
content.innerText = '国内用户推荐访问国内镜像以获得极速体验~';
|
||||
dialog.append(content);
|
||||
|
||||
const btnWrapper = document.createElement('div');
|
||||
btnWrapper.className = 'mirror-modal-btns';
|
||||
dialog.append(btnWrapper);
|
||||
|
||||
const cancelBtn = document.createElement('a');
|
||||
cancelBtn.className = 'mirror-modal-cancel-btn mirror-modal-btn';
|
||||
cancelBtn.innerText = '7 天内不再显示';
|
||||
btnWrapper.append(cancelBtn);
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
window.localStorage.setItem(ANTD_DOT_NOT_SHOW_MIRROR_MODAL, new Date().toISOString());
|
||||
document.body.removeChild(modal);
|
||||
document.head.removeChild(style);
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
const confirmBtn = document.createElement('a');
|
||||
confirmBtn.className = 'mirror-modal-confirm-btn mirror-modal-btn';
|
||||
confirmBtn.href = window.location.href.replace(window.location.host, 'ant-design.antgroup.com');
|
||||
confirmBtn.innerText = '🚀 立刻前往';
|
||||
btnWrapper.append(confirmBtn);
|
||||
|
||||
document.body.append(modal);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
})();
|
21
.dumi/theme/builtins/Previewer/Previewer.tsx
Normal file
21
.dumi/theme/builtins/Previewer/Previewer.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import type { IPreviewerProps } from 'dumi';
|
||||
import { useTabMeta } from 'dumi';
|
||||
import React from 'react';
|
||||
import CodePreviewer from './CodePreviewer';
|
||||
import DesignPreviewer from './DesignPreviewer';
|
||||
|
||||
export interface AntdPreviewerProps extends IPreviewerProps {
|
||||
originDebug?: IPreviewerProps['debug'];
|
||||
}
|
||||
|
||||
const Previewer: React.FC<AntdPreviewerProps> = (props) => {
|
||||
const tab = useTabMeta();
|
||||
|
||||
if (tab?.frontmatter.title === 'Design') {
|
||||
return <DesignPreviewer {...props} />;
|
||||
}
|
||||
|
||||
return <CodePreviewer {...props} />;
|
||||
};
|
||||
|
||||
export default Previewer;
|
@ -1,21 +1,10 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import type { IPreviewerProps } from 'dumi';
|
||||
import { useTabMeta } from 'dumi';
|
||||
import React from 'react';
|
||||
import CodePreviewer from './CodePreviewer';
|
||||
import DesignPreviewer from './DesignPreviewer';
|
||||
|
||||
export interface AntdPreviewerProps extends IPreviewerProps {
|
||||
originDebug?: IPreviewerProps['debug'];
|
||||
}
|
||||
const Previewer = React.lazy(() => import('./Previewer'));
|
||||
|
||||
const Previewer: React.FC<AntdPreviewerProps> = (props) => {
|
||||
const tab = useTabMeta();
|
||||
|
||||
if (tab?.frontmatter.title === 'Design') {
|
||||
return <DesignPreviewer {...props} />;
|
||||
}
|
||||
|
||||
return <CodePreviewer {...props} />;
|
||||
};
|
||||
|
||||
export default Previewer;
|
||||
export default (props: IPreviewerProps) => (
|
||||
<Suspense fallback={null}>
|
||||
<Previewer {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
|
@ -4,11 +4,11 @@ import classNames from 'classnames';
|
||||
import { useLocation, useSiteData } from 'dumi';
|
||||
import DumiSearchBar from 'dumi/theme-default/slots/SearchBar';
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Col, Modal, Popover, Row, Select } from 'antd';
|
||||
import { Col, Popover, Row, Select } from 'antd';
|
||||
import useLocale from '../../../hooks/useLocale';
|
||||
import DirectionIcon from '../../common/DirectionIcon';
|
||||
import * as utils from '../../utils';
|
||||
import { getThemeConfig, ping } from '../../utils';
|
||||
import { getThemeConfig } from '../../utils';
|
||||
import type { SiteContextProps } from '../SiteContext';
|
||||
import SiteContext from '../SiteContext';
|
||||
import Logo from './Logo';
|
||||
@ -109,16 +109,6 @@ const useStyle = createStyles(({ token, css }) => {
|
||||
};
|
||||
});
|
||||
|
||||
const SHOULD_OPEN_ANT_DESIGN_MIRROR_MODAL = 'ANT_DESIGN_DO_NOT_OPEN_MIRROR_MODAL';
|
||||
|
||||
function disableAntdMirrorModal() {
|
||||
window.localStorage.setItem(SHOULD_OPEN_ANT_DESIGN_MIRROR_MODAL, 'true');
|
||||
}
|
||||
|
||||
function shouldOpenAntdMirrorModal() {
|
||||
return !window.localStorage.getItem(SHOULD_OPEN_ANT_DESIGN_MIRROR_MODAL);
|
||||
}
|
||||
|
||||
interface HeaderState {
|
||||
menuVisible: boolean;
|
||||
windowWidth: number;
|
||||
@ -167,31 +157,6 @@ const Header: React.FC = () => {
|
||||
useEffect(() => {
|
||||
onWindowResize();
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
pingTimer.current = ping((status) => {
|
||||
if (status !== 'timeout' && status !== 'error') {
|
||||
if (
|
||||
// process.env.NODE_ENV === 'production' &&
|
||||
window.location.host !== 'ant-design.antgroup.com' &&
|
||||
shouldOpenAntdMirrorModal()
|
||||
) {
|
||||
Modal.confirm({
|
||||
title: '提示',
|
||||
content: '内网用户推荐访问国内镜像以获得极速体验~',
|
||||
okText: '🚀 立刻前往',
|
||||
cancelText: '不再弹出',
|
||||
closable: true,
|
||||
zIndex: 99999,
|
||||
onOk() {
|
||||
window.location.host = 'ant-design.antgroup.com';
|
||||
disableAntdMirrorModal();
|
||||
},
|
||||
onCancel() {
|
||||
disableAntdMirrorModal();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
if (pingTimer.current) {
|
||||
|
23
.dumirc.ts
23
.dumirc.ts
@ -3,6 +3,7 @@ import path from 'path';
|
||||
import rehypeAntd from './.dumi/rehypeAntd';
|
||||
import remarkAntd from './.dumi/remarkAntd';
|
||||
import { version } from './package.json';
|
||||
import * as fs from 'fs-extra';
|
||||
|
||||
export default defineConfig({
|
||||
conventionRoutes: {
|
||||
@ -44,56 +45,56 @@ export default defineConfig({
|
||||
},
|
||||
links: [
|
||||
{
|
||||
rel: 'preload',
|
||||
rel: 'prefetch',
|
||||
as: 'font',
|
||||
href: '//at.alicdn.com/t/webfont_6e11e43nfj.woff2',
|
||||
type: 'font/woff2',
|
||||
crossorigin: true,
|
||||
},
|
||||
{
|
||||
rel: 'preload',
|
||||
rel: 'prefetch',
|
||||
as: 'font',
|
||||
href: '//at.alicdn.com/t/webfont_6e11e43nfj.woff',
|
||||
type: 'font/woff',
|
||||
crossorigin: true,
|
||||
},
|
||||
{
|
||||
rel: 'preload',
|
||||
rel: 'prefetch',
|
||||
as: 'font',
|
||||
href: '//at.alicdn.com/t/webfont_6e11e43nfj.ttf',
|
||||
type: 'font/ttf',
|
||||
crossorigin: true,
|
||||
},
|
||||
{
|
||||
rel: 'preload',
|
||||
rel: 'prefetch',
|
||||
as: 'font',
|
||||
href: '//at.alicdn.com/t/webfont_exesdog9toj.woff2',
|
||||
type: 'font/woff2',
|
||||
crossorigin: true,
|
||||
},
|
||||
{
|
||||
rel: 'preload',
|
||||
rel: 'prefetch',
|
||||
as: 'font',
|
||||
href: '//at.alicdn.com/t/webfont_exesdog9toj.woff',
|
||||
type: 'font/woff',
|
||||
crossorigin: true,
|
||||
},
|
||||
{
|
||||
rel: 'preload',
|
||||
rel: 'prefetch',
|
||||
as: 'font',
|
||||
href: '//at.alicdn.com/t/webfont_exesdog9toj.ttf',
|
||||
type: 'font/ttf',
|
||||
crossorigin: true,
|
||||
},
|
||||
{
|
||||
rel: 'preload',
|
||||
rel: 'prefetch',
|
||||
as: 'font',
|
||||
href: '//at.alicdn.com/wf/webfont/exMpJIukiCms/Gsw2PSKrftc1yNWMNlXgw.woff2',
|
||||
type: 'font/woff2',
|
||||
crossorigin: true,
|
||||
},
|
||||
{
|
||||
rel: 'preload',
|
||||
rel: 'prefetch',
|
||||
as: 'font',
|
||||
href: '//at.alicdn.com/wf/webfont/exMpJIukiCms/vtu73by4O2gEBcvBuLgeu.woff',
|
||||
type: 'font/woff2',
|
||||
@ -158,4 +159,10 @@ export default defineConfig({
|
||||
})();
|
||||
`,
|
||||
],
|
||||
scripts: [
|
||||
{
|
||||
async: true,
|
||||
content: fs.readFileSync(path.join(__dirname, '.dumi', 'mirror-modal.js')).toString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@ -205,6 +205,7 @@ module.exports = {
|
||||
'@typescript-eslint/no-shadow': [2, { ignoreTypeValueShadow: true }],
|
||||
// https://github.com/typescript-eslint/typescript-eslint/issues/2528#issuecomment-689369395
|
||||
'no-undef': 0,
|
||||
'import/order': 0,
|
||||
},
|
||||
globals: {
|
||||
gtag: true,
|
||||
|
@ -4,6 +4,10 @@
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"proseWrap": "never",
|
||||
"importOrder": ["^(react|react-dom)$", "^([a-z]|@[a-z])", ".*"],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"plugins": ["@trivago/prettier-plugin-sort-imports"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ".prettierrc",
|
||||
|
@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import React, { createRef, forwardRef, useContext } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import ResizeObserver from 'rc-resize-observer';
|
||||
import omit from 'rc-util/lib/omit';
|
||||
import React, { createRef, forwardRef, useContext } from 'react';
|
||||
|
||||
import throttleByAnimationFrame from '../_util/throttleByAnimationFrame';
|
||||
import type { ConfigConsumerProps } from '../config-provider';
|
||||
import { ConfigContext } from '../config-provider';
|
||||
|
@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { fireEvent, render, waitFakeTimer } from '../../../tests/utils';
|
||||
import { resetWarned } from '../../_util/warning';
|
||||
|
||||
|
@ -1,11 +1,13 @@
|
||||
import type { CSSProperties, FC } from 'react';
|
||||
import React, { useContext, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
HsbaColorType,
|
||||
ColorPickerProps as RcColorPickerProps,
|
||||
} from '@rc-component/color-picker';
|
||||
import classNames from 'classnames';
|
||||
import useMergedState from 'rc-util/lib/hooks/useMergedState';
|
||||
import type { CSSProperties, FC } from 'react';
|
||||
import React, { useContext, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import genPurePanel from '../_util/PurePanel';
|
||||
import { getStatusClassNames } from '../_util/statusUtils';
|
||||
import warning from '../_util/warning';
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import StickyBox from 'react-sticky-box';
|
||||
|
||||
import type { TabsProps } from 'antd';
|
||||
import { Tabs, theme } from 'antd';
|
||||
import StickyBox from 'react-sticky-box';
|
||||
|
||||
const items = new Array(3).fill(null).map((_, i) => {
|
||||
const id = String(i + 1);
|
||||
|
@ -48,7 +48,7 @@ Common props ref:[Common props](/docs/react/common-props)
|
||||
| --- | --- | --- | --- | --- |
|
||||
| activeKey | Current TabPane's key | string | - | |
|
||||
| addIcon | Customize add icon | ReactNode | - | 4.4.0 |
|
||||
| animated | Whether to change tabs with animation.` | boolean \| { inkBar: boolean, tabPane: boolean } | { inkBar: true, tabPane: false } | |
|
||||
| animated | Whether to change tabs with animation. | boolean \| { inkBar: boolean, tabPane: boolean } | { inkBar: true, tabPane: false } | |
|
||||
| centered | Centers tabs | boolean | false | 4.4.0 |
|
||||
| defaultActiveKey | Initial active TabPane's key, if `activeKey` is not set | string | - | |
|
||||
| hideAdd | Hide plus icon or not. Only works while `type="editable-card"` | boolean | false | |
|
||||
|
@ -50,7 +50,7 @@ Ant Design 依次提供了三级选项卡,分别用于不同的场景。
|
||||
| --- | --- | --- | --- | --- |
|
||||
| activeKey | 当前激活 tab 面板的 key | string | - | |
|
||||
| addIcon | 自定义添加按钮 | ReactNode | - | 4.4.0 |
|
||||
| animated | 是否使用动画切换 Tabs` | boolean\| { inkBar: boolean, tabPane: boolean } | { inkBar: true, tabPane: false } | |
|
||||
| animated | 是否使用动画切换 Tabs | boolean\| { inkBar: boolean, tabPane: boolean } | { inkBar: true, tabPane: false } | |
|
||||
| centered | 标签居中展示 | boolean | false | 4.4.0 |
|
||||
| defaultActiveKey | 初始化选中面板的 key,如果没有设置 activeKey | string | `第一个面板` | |
|
||||
| hideAdd | 是否隐藏加号图标,在 `type="editable-card"` 时有效 | boolean | false | |
|
||||
|
@ -9,7 +9,7 @@ exports[`renders components/watermark/demo/basic.tsx extend context correctly 1`
|
||||
style="height: 500px;"
|
||||
/>
|
||||
<div
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 220px;"
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 218px;"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@ -49,7 +49,7 @@ exports[`renders components/watermark/demo/custom.tsx extend context correctly 1
|
||||
style="z-index: 10; width: 100%; max-width: 800px; position: relative;"
|
||||
/>
|
||||
<div
|
||||
style="z-index: 11; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 220px;"
|
||||
style="z-index: 11; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 218px;"
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
@ -536,7 +536,7 @@ exports[`renders components/watermark/demo/custom.tsx extend context correctly 1
|
||||
/>
|
||||
<div
|
||||
class="ant-slider-track"
|
||||
style="left: 0%; width: 16%;"
|
||||
style="left: 0%; width: 15.151515151515152%;"
|
||||
/>
|
||||
<div
|
||||
class="ant-slider-step"
|
||||
@ -544,11 +544,11 @@ exports[`renders components/watermark/demo/custom.tsx extend context correctly 1
|
||||
<div
|
||||
aria-disabled="false"
|
||||
aria-valuemax="100"
|
||||
aria-valuemin="0"
|
||||
aria-valuemin="1"
|
||||
aria-valuenow="16"
|
||||
class="ant-slider-handle"
|
||||
role="slider"
|
||||
style="left: 16%; transform: translateX(-50%);"
|
||||
style="left: 15.151515151515152%; transform: translateX(-50%);"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
@ -1271,7 +1271,7 @@ Array [
|
||||
style="position: relative;"
|
||||
>
|
||||
<div
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 220px;"
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 218px;"
|
||||
/>
|
||||
</div>,
|
||||
]
|
||||
|
@ -163,7 +163,7 @@ exports[`renders components/watermark/demo/custom.tsx correctly 1`] = `
|
||||
/>
|
||||
<div
|
||||
class="ant-slider-track"
|
||||
style="left:0%;width:16%"
|
||||
style="left:0%;width:15.151515151515152%"
|
||||
/>
|
||||
<div
|
||||
class="ant-slider-step"
|
||||
@ -171,11 +171,11 @@ exports[`renders components/watermark/demo/custom.tsx correctly 1`] = `
|
||||
<div
|
||||
aria-disabled="false"
|
||||
aria-valuemax="100"
|
||||
aria-valuemin="0"
|
||||
aria-valuemin="1"
|
||||
aria-valuenow="16"
|
||||
class="ant-slider-handle"
|
||||
role="slider"
|
||||
style="left:16%;transform:translateX(-50%)"
|
||||
style="left:15.151515151515152%;transform:translateX(-50%)"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
|
@ -7,7 +7,7 @@ exports[`Watermark Image watermark snapshot 1`] = `
|
||||
style="position: relative;"
|
||||
>
|
||||
<div
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 440px;"
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 470px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -20,7 +20,7 @@ exports[`Watermark Interleaved watermark backgroundSize is correct 1`] = `
|
||||
style="position: relative;"
|
||||
>
|
||||
<div
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 600px;"
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 720px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -33,7 +33,7 @@ exports[`Watermark Invalid image watermark 1`] = `
|
||||
style="position: relative;"
|
||||
>
|
||||
<div
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 440px;"
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 470px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -46,7 +46,7 @@ exports[`Watermark MutationObserver should work properly 1`] = `
|
||||
style="position: relative;"
|
||||
>
|
||||
<div
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 232px;"
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 229px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -59,7 +59,7 @@ exports[`Watermark Observe the modification of style 1`] = `
|
||||
style="position: relative;"
|
||||
>
|
||||
<div
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: -250px -250px; background-image: url('data:image/png;base64,00'); background-size: 232px;"
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: -250px -250px; background-image: url('data:image/png;base64,00'); background-size: 229px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -85,7 +85,7 @@ exports[`Watermark The watermark should render successfully 1`] = `
|
||||
style="position: relative;"
|
||||
>
|
||||
<div
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 220px;"
|
||||
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 218px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -55,7 +55,7 @@ describe('Watermark', () => {
|
||||
/>,
|
||||
);
|
||||
const target = container.querySelector<HTMLDivElement>('.watermark div');
|
||||
expect(target?.style.backgroundSize).toBe('600px');
|
||||
expect(target?.style.backgroundSize).toBe('720px');
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
@ -103,7 +103,7 @@ const App: React.FC = () => {
|
||||
<ColorPicker />
|
||||
</Form.Item>
|
||||
<Form.Item name="fontSize" label="FontSize">
|
||||
<Slider step={1} min={0} max={100} />
|
||||
<Slider step={1} min={1} max={100} />
|
||||
</Form.Item>
|
||||
<Form.Item name="zIndex" label="zIndex">
|
||||
<Slider step={1} min={0} max={100} />
|
||||
|
@ -1,13 +1,15 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { useMutateObserver } from '@rc-component/mutate-observer';
|
||||
import classNames from 'classnames';
|
||||
import React, { useEffect } from 'react';
|
||||
import { reRendering } from './utils';
|
||||
|
||||
import theme from '../theme';
|
||||
import useWatermark from './useWatermark';
|
||||
import useRafDebounce from './useRafDebounce';
|
||||
import useContent from './useContent';
|
||||
import WatermarkContext from './context';
|
||||
import type { WatermarkContextProps } from './context';
|
||||
import useClips, { FontGap } from './useClips';
|
||||
import useRafDebounce from './useRafDebounce';
|
||||
import useWatermark from './useWatermark';
|
||||
import { getPixelRatio, reRendering } from './utils';
|
||||
|
||||
export interface WatermarkProps {
|
||||
zIndex?: number;
|
||||
@ -116,27 +118,89 @@ const Watermark: React.FC<WatermarkProps> = (props) => {
|
||||
}, [container, subElements]);
|
||||
|
||||
// ============================ Content =============================
|
||||
/**
|
||||
* Get the width and height of the watermark. The default values are as follows
|
||||
* Image: [120, 64]; Content: It's calculated by content;
|
||||
*/
|
||||
const getMarkSize = (ctx: CanvasRenderingContext2D) => {
|
||||
let defaultWidth = 120;
|
||||
let defaultHeight = 64;
|
||||
if (!image && ctx.measureText) {
|
||||
ctx.font = `${Number(fontSize)}px ${fontFamily}`;
|
||||
const contents = Array.isArray(content) ? content : [content];
|
||||
const sizes = contents.map((item) => {
|
||||
const metrics = ctx.measureText(item!);
|
||||
|
||||
return [metrics.width, metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent];
|
||||
});
|
||||
defaultWidth = Math.ceil(Math.max(...sizes.map((size) => size[0])));
|
||||
defaultHeight =
|
||||
Math.ceil(Math.max(...sizes.map((size) => size[1]))) * contents.length +
|
||||
(contents.length - 1) * FontGap;
|
||||
}
|
||||
return [width ?? defaultWidth, height ?? defaultHeight] as const;
|
||||
};
|
||||
|
||||
const getClips = useClips();
|
||||
|
||||
const [watermarkInfo, setWatermarkInfo] = React.useState<[base64: string, contentWidth: number]>(
|
||||
null!,
|
||||
);
|
||||
|
||||
// Generate new Watermark content
|
||||
const renderWatermark = useContent(
|
||||
{
|
||||
...props,
|
||||
rotate,
|
||||
gap,
|
||||
},
|
||||
(base64, contentWidth) => {
|
||||
setWatermarkInfo([base64, contentWidth]);
|
||||
},
|
||||
);
|
||||
const renderWatermark = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
const ratio = getPixelRatio();
|
||||
const [markWidth, markHeight] = getMarkSize(ctx);
|
||||
|
||||
const drawCanvas = (
|
||||
drawContent?: NonNullable<WatermarkProps['content']> | HTMLImageElement,
|
||||
) => {
|
||||
const [nextClips, clipWidth] = getClips(
|
||||
drawContent || '',
|
||||
rotate,
|
||||
ratio,
|
||||
markWidth,
|
||||
markHeight,
|
||||
{
|
||||
color,
|
||||
fontSize,
|
||||
fontStyle,
|
||||
fontWeight,
|
||||
fontFamily,
|
||||
},
|
||||
gapX,
|
||||
gapY,
|
||||
);
|
||||
|
||||
setWatermarkInfo([nextClips, clipWidth]);
|
||||
};
|
||||
|
||||
if (image) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
drawCanvas(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
drawCanvas(content);
|
||||
};
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.src = image;
|
||||
} else {
|
||||
drawCanvas(content);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const syncWatermark = useRafDebounce(renderWatermark);
|
||||
|
||||
// ============================= Effect =============================
|
||||
// Append watermark to the container
|
||||
const [appendWatermark, removeWatermark, isWatermarkEle] = useWatermark(markStyle, gapX);
|
||||
const [appendWatermark, removeWatermark, isWatermarkEle] = useWatermark(markStyle);
|
||||
|
||||
useEffect(() => {
|
||||
if (watermarkInfo) {
|
||||
|
136
components/watermark/useClips.ts
Normal file
136
components/watermark/useClips.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import type { WatermarkProps } from '.';
|
||||
|
||||
export const FontGap = 3;
|
||||
|
||||
function prepareCanvas(
|
||||
width: number,
|
||||
height: number,
|
||||
ratio: number = 1,
|
||||
): [
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
realWidth: number,
|
||||
realHeight: number,
|
||||
] {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
const realWidth = width * ratio;
|
||||
const realHeight = height * ratio;
|
||||
canvas.setAttribute('width', `${realWidth}px`);
|
||||
canvas.setAttribute('height', `${realHeight}px`);
|
||||
ctx.save();
|
||||
|
||||
return [ctx, canvas, realWidth, realHeight];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the clips of text content.
|
||||
* This is a lazy hook function since SSR no need this
|
||||
*/
|
||||
export default function useClips() {
|
||||
// Get single clips
|
||||
function getClips(
|
||||
content: NonNullable<WatermarkProps['content']> | HTMLImageElement,
|
||||
rotate: number,
|
||||
ratio: number,
|
||||
width: number,
|
||||
height: number,
|
||||
font: Required<NonNullable<WatermarkProps['font']>>,
|
||||
gapX: number,
|
||||
gapY: number,
|
||||
): [dataURL: string, finalWidth: number, finalHeight: number] {
|
||||
// ================= Text / Image =================
|
||||
const [ctx, canvas, contentWidth, contentHeight] = prepareCanvas(width, height, ratio);
|
||||
|
||||
if (content instanceof HTMLImageElement) {
|
||||
// Image
|
||||
ctx.drawImage(content, 0, 0, contentWidth, contentHeight);
|
||||
} else {
|
||||
// Text
|
||||
const { color, fontSize, fontStyle, fontWeight, fontFamily } = font;
|
||||
const mergedFontSize = Number(fontSize) * ratio;
|
||||
|
||||
ctx.font = `${fontStyle} normal ${fontWeight} ${mergedFontSize}px/${height}px ${fontFamily}`;
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
const contents = Array.isArray(content) ? content : [content];
|
||||
contents?.forEach((item, index) => {
|
||||
ctx.fillText(item ?? '', contentWidth / 2, index * (mergedFontSize + FontGap * ratio));
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Rotate ====================
|
||||
const angle = (Math.PI / 180) * Number(rotate);
|
||||
const maxSize = Math.max(width, height);
|
||||
const [rCtx, rCanvas, realMaxSize] = prepareCanvas(maxSize, maxSize, ratio);
|
||||
|
||||
// Copy from `ctx` and rotate
|
||||
rCtx.translate(realMaxSize / 2, realMaxSize / 2);
|
||||
rCtx.rotate(angle);
|
||||
rCtx.drawImage(canvas, -contentWidth / 2, -contentHeight / 2);
|
||||
|
||||
// Get boundary of rotated text
|
||||
function getRotatePos(x: number, y: number) {
|
||||
const targetX = x * Math.cos(angle) - y * Math.sin(angle);
|
||||
const targetY = x * Math.sin(angle) + y * Math.cos(angle);
|
||||
return [targetX, targetY];
|
||||
}
|
||||
|
||||
let left = 0;
|
||||
let right = 0;
|
||||
let top = 0;
|
||||
let bottom = 0;
|
||||
|
||||
const halfWidth = contentWidth / 2;
|
||||
const halfHeight = contentHeight / 2;
|
||||
const points = [
|
||||
[0 - halfWidth, 0 - halfHeight],
|
||||
[0 + halfWidth, 0 - halfHeight],
|
||||
[0 + halfWidth, 0 + halfHeight],
|
||||
[0 - halfWidth, 0 + halfHeight],
|
||||
];
|
||||
points.forEach(([x, y]) => {
|
||||
const [targetX, targetY] = getRotatePos(x, y);
|
||||
left = Math.min(left, targetX);
|
||||
right = Math.max(right, targetX);
|
||||
top = Math.min(top, targetY);
|
||||
bottom = Math.max(bottom, targetY);
|
||||
});
|
||||
|
||||
const cutLeft = left + realMaxSize / 2;
|
||||
const cutTop = top + realMaxSize / 2;
|
||||
const cutWidth = right - left;
|
||||
const cutHeight = bottom - top;
|
||||
|
||||
// ================ Fill Alternate ================
|
||||
const realGapX = gapX * ratio;
|
||||
const realGapY = gapY * ratio;
|
||||
const filledWidth = (cutWidth + realGapX) * 2;
|
||||
const filledHeight = cutHeight + realGapY;
|
||||
|
||||
const [fCtx, fCanvas] = prepareCanvas(filledWidth, filledHeight);
|
||||
|
||||
function drawImg(targetX = 0, targetY = 0) {
|
||||
fCtx.drawImage(
|
||||
rCanvas,
|
||||
cutLeft,
|
||||
cutTop,
|
||||
cutWidth,
|
||||
cutHeight,
|
||||
targetX,
|
||||
targetY,
|
||||
cutWidth,
|
||||
cutHeight,
|
||||
);
|
||||
}
|
||||
drawImg();
|
||||
drawImg(cutWidth + realGapX, -cutHeight / 2 - realGapY / 2);
|
||||
drawImg(cutWidth + realGapX, +cutHeight / 2 + realGapY / 2);
|
||||
|
||||
return [fCanvas.toDataURL(), filledWidth / ratio, filledHeight / ratio];
|
||||
}
|
||||
|
||||
return getClips;
|
||||
}
|
@ -1,156 +0,0 @@
|
||||
import type { WatermarkProps } from '.';
|
||||
import useToken from '../theme/useToken';
|
||||
import { BaseSize, FontGap } from './useWatermark';
|
||||
import { getPixelRatio, rotateWatermark } from './utils';
|
||||
|
||||
export default function useContent(
|
||||
props: Pick<WatermarkProps, 'width' | 'height' | 'image' | 'content' | 'font'> &
|
||||
Required<Pick<WatermarkProps, 'rotate' | 'gap'>>,
|
||||
callback: (base64Url: string, markWidth: number) => void,
|
||||
) {
|
||||
const { rotate, width, height, image, content, font = {}, gap } = props;
|
||||
|
||||
const [, token] = useToken();
|
||||
|
||||
const {
|
||||
color = token.colorFill,
|
||||
fontSize = token.fontSizeLG,
|
||||
fontWeight = 'normal',
|
||||
fontStyle = 'normal',
|
||||
fontFamily = 'sans-serif',
|
||||
} = font;
|
||||
|
||||
const [gapX, gapY] = gap;
|
||||
|
||||
/**
|
||||
* Get the width and height of the watermark. The default values are as follows
|
||||
* Image: [120, 64]; Content: It's calculated by content;
|
||||
*/
|
||||
const getMarkSize = (ctx: CanvasRenderingContext2D) => {
|
||||
let defaultWidth = 120;
|
||||
let defaultHeight = 64;
|
||||
if (!image && ctx.measureText) {
|
||||
ctx.font = `${Number(fontSize)}px ${fontFamily}`;
|
||||
const contents = Array.isArray(content) ? content : [content];
|
||||
const widths = contents.map((item) => ctx.measureText(item!).width);
|
||||
defaultWidth = Math.ceil(Math.max(...widths));
|
||||
defaultHeight = Number(fontSize) * contents.length + (contents.length - 1) * FontGap;
|
||||
}
|
||||
return [width ?? defaultWidth, height ?? defaultHeight] as const;
|
||||
};
|
||||
|
||||
const fillTexts = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
drawX: number,
|
||||
drawY: number,
|
||||
drawWidth: number,
|
||||
drawHeight: number,
|
||||
) => {
|
||||
const ratio = getPixelRatio();
|
||||
const mergedFontSize = Number(fontSize) * ratio;
|
||||
ctx.font = `${fontStyle} normal ${fontWeight} ${mergedFontSize}px/${drawHeight}px ${fontFamily}`;
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.translate(drawWidth / 2, 0);
|
||||
const contents = Array.isArray(content) ? content : [content];
|
||||
contents?.forEach((item, index) => {
|
||||
ctx.fillText(item ?? '', drawX, drawY + index * (mergedFontSize + FontGap * ratio));
|
||||
});
|
||||
};
|
||||
|
||||
const drawText = (
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
drawX: number,
|
||||
drawY: number,
|
||||
drawWidth: number,
|
||||
drawHeight: number,
|
||||
alternateRotateX: number,
|
||||
alternateRotateY: number,
|
||||
alternateDrawX: number,
|
||||
alternateDrawY: number,
|
||||
markWidth: number,
|
||||
) => {
|
||||
fillTexts(ctx, drawX, drawY, drawWidth, drawHeight);
|
||||
/** Fill the interleaved text after rotation */
|
||||
ctx.restore();
|
||||
rotateWatermark(ctx, alternateRotateX, alternateRotateY, rotate);
|
||||
fillTexts(ctx, alternateDrawX, alternateDrawY, drawWidth, drawHeight);
|
||||
callback(canvas.toDataURL(), markWidth);
|
||||
};
|
||||
|
||||
const renderWatermark = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
const ratio = getPixelRatio();
|
||||
const [markWidth, markHeight] = getMarkSize(ctx);
|
||||
const canvasWidth = (gapX + markWidth) * ratio;
|
||||
const canvasHeight = (gapY + markHeight) * ratio;
|
||||
canvas.setAttribute('width', `${canvasWidth * BaseSize}px`);
|
||||
canvas.setAttribute('height', `${canvasHeight * BaseSize}px`);
|
||||
|
||||
const drawX = (gapX * ratio) / 2;
|
||||
const drawY = (gapY * ratio) / 2;
|
||||
const drawWidth = markWidth * ratio;
|
||||
const drawHeight = markHeight * ratio;
|
||||
const rotateX = (drawWidth + gapX * ratio) / 2;
|
||||
const rotateY = (drawHeight + gapY * ratio) / 2;
|
||||
/** Alternate drawing parameters */
|
||||
const alternateDrawX = drawX + canvasWidth;
|
||||
const alternateDrawY = drawY + canvasHeight;
|
||||
const alternateRotateX = rotateX + canvasWidth;
|
||||
const alternateRotateY = rotateY + canvasHeight;
|
||||
|
||||
ctx.save();
|
||||
rotateWatermark(ctx, rotateX, rotateY, rotate);
|
||||
|
||||
if (image) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
|
||||
/** Draw interleaved pictures after rotation */
|
||||
ctx.restore();
|
||||
rotateWatermark(ctx, alternateRotateX, alternateRotateY, rotate);
|
||||
ctx.drawImage(img, alternateDrawX, alternateDrawY, drawWidth, drawHeight);
|
||||
callback(canvas.toDataURL(), markWidth);
|
||||
};
|
||||
img.onerror = () =>
|
||||
drawText(
|
||||
canvas,
|
||||
ctx,
|
||||
drawX,
|
||||
drawY,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
alternateRotateX,
|
||||
alternateRotateY,
|
||||
alternateDrawX,
|
||||
alternateDrawY,
|
||||
markWidth,
|
||||
);
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.src = image;
|
||||
} else {
|
||||
drawText(
|
||||
canvas,
|
||||
ctx,
|
||||
drawX,
|
||||
drawY,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
alternateRotateX,
|
||||
alternateRotateY,
|
||||
alternateDrawX,
|
||||
alternateDrawY,
|
||||
markWidth,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return renderWatermark;
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { getStyleStr } from './utils';
|
||||
|
||||
/**
|
||||
@ -16,7 +17,6 @@ export type AppendWatermark = (
|
||||
|
||||
export default function useWatermark(
|
||||
markStyle: React.CSSProperties,
|
||||
gapX: number,
|
||||
): [
|
||||
appendWatermark: AppendWatermark,
|
||||
removeWatermark: (container: HTMLElement) => void,
|
||||
@ -38,7 +38,7 @@ export default function useWatermark(
|
||||
getStyleStr({
|
||||
...markStyle,
|
||||
backgroundImage: `url('${base64Url}')`,
|
||||
backgroundSize: `${(gapX + markWidth) * BaseSize}px`,
|
||||
backgroundSize: `${Math.floor(markWidth)}px`,
|
||||
}),
|
||||
);
|
||||
container.append(watermarkEle);
|
||||
|
@ -14,18 +14,6 @@ export function getPixelRatio() {
|
||||
return window.devicePixelRatio || 1;
|
||||
}
|
||||
|
||||
/** Rotate with the watermark as the center point */
|
||||
export function rotateWatermark(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rotateX: number,
|
||||
rotateY: number,
|
||||
rotate: number,
|
||||
) {
|
||||
ctx.translate(rotateX, rotateY);
|
||||
ctx.rotate((Math.PI / 180) * Number(rotate));
|
||||
ctx.translate(-rotateX, -rotateY);
|
||||
}
|
||||
|
||||
/** Whether to re-render the watermark */
|
||||
export const reRendering = (mutation: MutationRecord, isWatermarkEle: (ele: any) => boolean) => {
|
||||
let flag = false;
|
||||
|
@ -181,6 +181,7 @@
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^14.4.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/gtag.js": "^0.0.13",
|
||||
"@types/http-server": "^0.12.1",
|
||||
|
Loading…
Reference in New Issue
Block a user