chore: auto merge branches (#44328)

chore: feature merge master
This commit is contained in:
github-actions[bot] 2023-08-21 16:17:00 +00:00 committed by GitHub
commit f34ff52cec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 471 additions and 272 deletions

172
.dumi/mirror-modal.js Normal file
View 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';
}
})();

View 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;

View File

@ -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>
);

View File

@ -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) {

View File

@ -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(),
},
],
});

View File

@ -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,

View File

@ -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",

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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);

View File

@ -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 | |

View File

@ -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 | |

View File

@ -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>,
]

View File

@ -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>

View File

@ -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>

View File

@ -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();
});

View File

@ -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} />

View File

@ -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) {

View 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;
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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;

View File

@ -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",