feat: New Component Watermark (#39064)

* feat: New Component Watermark

* docs: add watermark docs

* docs: add watermark demo

* test: add watermark test

* test: add watermark snapshot

* chore: add jest-canvas-mock

* feat: Watermark calculates the width and height of content by default

* docs: update docs

* docs: update demo

* test: update snapshot

* docs: update docs

* chore: update bundlesize

* chore: Optimize code logic

* chore: update size-limit

* test: update watermark snapshot
This commit is contained in:
JarvisArt 2022-12-08 18:06:36 +08:00 committed by GitHub
parent 451d2f6ee2
commit 6f83c63d74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 2860 additions and 3 deletions

View File

@ -34,7 +34,7 @@ function getTestRegex(libDir) {
module.exports = {
verbose: true,
testEnvironment: 'jsdom',
setupFiles: ['./tests/setup.js'],
setupFiles: ['./tests/setup.js', 'jest-canvas-mock'],
setupFilesAfterEnv: ['./tests/setupAfterEnv.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'md'],
modulePathIgnorePatterns: ['/_site/'],

View File

@ -67,6 +67,7 @@ exports[`antd exports modules correctly 1`] = `
"TreeSelect",
"Typography",
"Upload",
"Watermark",
"message",
"notification",
"theme",

View File

@ -150,6 +150,8 @@ export { default as Typography } from './typography';
export type { TypographyProps } from './typography';
export { default as Upload } from './upload';
export type { UploadFile, UploadProps } from './upload';
export { default as Watermark } from './watermark';
export type { WatermarkProps } from './watermark';
export { default as QRCode } from './qrcode';
export type { QRCodeProps, QRPropsCanvas } from './qrcode/interface';
export { default as version } from './version';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,788 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders ./components/watermark/demo/basic.tsx correctly 1`] = `
<div
style="position:relative"
>
<div
style="height:500px"
/>
</div>
`;
exports[`renders ./components/watermark/demo/custom.tsx correctly 1`] = `
<div
style="display:flex"
>
<div
style="position:relative"
>
<article
class="ant-typography"
>
<div
class="ant-typography"
>
The light-speed iteration of the digital world makes products more complex. However, human consciousness and attention resources are limited. Facing this design contradiction, the pursuit of natural interaction will be the consistent direction of Ant Design.
</div>
<div
class="ant-typography"
>
Natural user cognition: According to cognitive psychology, about 80% of external information is obtained through visual channels. The most important visual elements in the interface design, including layout, colors, illustrations, icons, etc., should fully absorb the laws of nature, thereby reducing the user's cognitive cost and bringing authentic and smooth feelings. In some scenarios, opportunely adding other sensory channels such as hearing, touch can create a richer and more natural product experience.
</div>
<div
class="ant-typography"
>
Natural user behavior: In the interaction with the system, the designer should fully understand the relationship between users, system roles, and task objectives, and also contextually organize system functions and services. At the same time, a series of methods such as behavior analysis, artificial intelligence and sensors could be applied to assist users to make effective decisions and reduce extra operations of users, to save users' mental and physical resources and make human-computer interaction more natural.
</div>
</article>
<img
alt="示例图片"
src="https://gw.alipayobjects.com/mdn/rms_08e378/afts/img/A*zx7LTI_ECSAAAAAAAAAAAABkARQnAQ"
style="z-index:10;width:100%;max-width:800px;position:relative"
/>
</div>
<form
class="ant-form ant-form-vertical"
style="width:280px;flex-shrink:0;border-left:1px solid #eee;padding-left:20px;margin-left:20px"
>
<div
class="ant-form-item"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label"
>
<label
class=""
for="content"
title="Content"
>
Content
</label>
</div>
<div
class="ant-col ant-form-item-control"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<input
class="ant-input"
id="content"
placeholder="请输入"
type="text"
value="Ant Design"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label"
>
<label
class=""
for="color"
title="Color"
>
Color
</label>
</div>
<div
class="ant-col ant-form-item-control"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
style="padding:4px;background:#fff;border-radius:2px;border:1px solid #dedede;display:inline-block;cursor:pointer"
>
<div
style="width:36px;height:14px;border-radius:2px;background:rgba(0, 0, 0, 0.15)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label"
>
<label
class=""
for="fontSize"
title="FontSize"
>
FontSize
</label>
</div>
<div
class="ant-col ant-form-item-control"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-slider ant-slider-horizontal"
>
<div
class="ant-slider-rail"
/>
<div
class="ant-slider-track"
style="left:0%;width:16%"
/>
<div
class="ant-slider-step"
/>
<div
aria-disabled="false"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow="16"
class="ant-slider-handle"
role="slider"
style="left:16%;transform:translateX(-50%)"
tabindex="0"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label"
>
<label
class=""
for="zIndex"
title="zIndex"
>
zIndex
</label>
</div>
<div
class="ant-col ant-form-item-control"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-slider ant-slider-horizontal"
>
<div
class="ant-slider-rail"
/>
<div
class="ant-slider-track"
style="left:0%;width:11%"
/>
<div
class="ant-slider-step"
/>
<div
aria-disabled="false"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow="11"
class="ant-slider-handle"
role="slider"
style="left:11%;transform:translateX(-50%)"
tabindex="0"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label"
>
<label
class=""
for="rotate"
title="Rotate"
>
Rotate
</label>
</div>
<div
class="ant-col ant-form-item-control"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-slider ant-slider-horizontal"
>
<div
class="ant-slider-rail"
/>
<div
class="ant-slider-track"
style="left:0%;width:43.888888888888886%"
/>
<div
class="ant-slider-step"
/>
<div
aria-disabled="false"
aria-valuemax="180"
aria-valuemin="-180"
aria-valuenow="-22"
class="ant-slider-handle"
role="slider"
style="left:43.888888888888886%;transform:translateX(-50%)"
tabindex="0"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item"
style="margin-bottom:0"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label"
>
<label
class=""
title="Gap"
>
Gap
</label>
</div>
<div
class="ant-col ant-form-item-control"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-space ant-space-horizontal ant-space-align-baseline"
style="display:flex"
>
<div
class="ant-space-item"
style="margin-right:8px"
>
<div
class="ant-form-item"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-control"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-input-number ant-input-number-in-form-item"
style="width:100%"
>
<div
class="ant-input-number-handler-wrap"
>
<span
aria-disabled="false"
aria-label="Increase Value"
class="ant-input-number-handler ant-input-number-handler-up"
role="button"
unselectable="on"
>
<span
aria-label="up"
class="anticon anticon-up ant-input-number-handler-up-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="up"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M890.5 755.3L537.9 269.2c-12.8-17.6-39-17.6-51.7 0L133.5 755.3A8 8 0 00140 768h75c5.1 0 9.9-2.5 12.9-6.6L512 369.8l284.1 391.6c3 4.1 7.8 6.6 12.9 6.6h75c6.5 0 10.3-7.4 6.5-12.7z"
/>
</svg>
</span>
</span>
<span
aria-disabled="false"
aria-label="Decrease Value"
class="ant-input-number-handler ant-input-number-handler-down"
role="button"
unselectable="on"
>
<span
aria-label="down"
class="anticon anticon-down ant-input-number-handler-down-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="down"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
/>
</svg>
</span>
</span>
</div>
<div
class="ant-input-number-input-wrap"
>
<input
aria-valuenow="200"
autocomplete="off"
class="ant-input-number-input"
id="gap_0"
placeholder="gapX"
role="spinbutton"
step="1"
value="200"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-space-item"
>
<div
class="ant-form-item"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-control"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-input-number ant-input-number-in-form-item"
style="width:100%"
>
<div
class="ant-input-number-handler-wrap"
>
<span
aria-disabled="false"
aria-label="Increase Value"
class="ant-input-number-handler ant-input-number-handler-up"
role="button"
unselectable="on"
>
<span
aria-label="up"
class="anticon anticon-up ant-input-number-handler-up-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="up"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M890.5 755.3L537.9 269.2c-12.8-17.6-39-17.6-51.7 0L133.5 755.3A8 8 0 00140 768h75c5.1 0 9.9-2.5 12.9-6.6L512 369.8l284.1 391.6c3 4.1 7.8 6.6 12.9 6.6h75c6.5 0 10.3-7.4 6.5-12.7z"
/>
</svg>
</span>
</span>
<span
aria-disabled="false"
aria-label="Decrease Value"
class="ant-input-number-handler ant-input-number-handler-down"
role="button"
unselectable="on"
>
<span
aria-label="down"
class="anticon anticon-down ant-input-number-handler-down-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="down"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
/>
</svg>
</span>
</span>
</div>
<div
class="ant-input-number-input-wrap"
>
<input
aria-valuenow="200"
autocomplete="off"
class="ant-input-number-input"
id="gap_1"
placeholder="gapY"
role="spinbutton"
step="1"
value="200"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item"
style="margin-bottom:0"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label"
>
<label
class=""
title="Offset"
>
Offset
</label>
</div>
<div
class="ant-col ant-form-item-control"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-space ant-space-horizontal ant-space-align-baseline"
style="display:flex"
>
<div
class="ant-space-item"
style="margin-right:8px"
>
<div
class="ant-form-item"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-control"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-input-number ant-input-number-in-form-item"
style="width:100%"
>
<div
class="ant-input-number-handler-wrap"
>
<span
aria-disabled="false"
aria-label="Increase Value"
class="ant-input-number-handler ant-input-number-handler-up"
role="button"
unselectable="on"
>
<span
aria-label="up"
class="anticon anticon-up ant-input-number-handler-up-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="up"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M890.5 755.3L537.9 269.2c-12.8-17.6-39-17.6-51.7 0L133.5 755.3A8 8 0 00140 768h75c5.1 0 9.9-2.5 12.9-6.6L512 369.8l284.1 391.6c3 4.1 7.8 6.6 12.9 6.6h75c6.5 0 10.3-7.4 6.5-12.7z"
/>
</svg>
</span>
</span>
<span
aria-disabled="false"
aria-label="Decrease Value"
class="ant-input-number-handler ant-input-number-handler-down"
role="button"
unselectable="on"
>
<span
aria-label="down"
class="anticon anticon-down ant-input-number-handler-down-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="down"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
/>
</svg>
</span>
</span>
</div>
<div
class="ant-input-number-input-wrap"
>
<input
autocomplete="off"
class="ant-input-number-input"
id="offset_0"
placeholder="offsetLeft"
role="spinbutton"
step="1"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-space-item"
>
<div
class="ant-form-item"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-control"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-input-number ant-input-number-in-form-item"
style="width:100%"
>
<div
class="ant-input-number-handler-wrap"
>
<span
aria-disabled="false"
aria-label="Increase Value"
class="ant-input-number-handler ant-input-number-handler-up"
role="button"
unselectable="on"
>
<span
aria-label="up"
class="anticon anticon-up ant-input-number-handler-up-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="up"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M890.5 755.3L537.9 269.2c-12.8-17.6-39-17.6-51.7 0L133.5 755.3A8 8 0 00140 768h75c5.1 0 9.9-2.5 12.9-6.6L512 369.8l284.1 391.6c3 4.1 7.8 6.6 12.9 6.6h75c6.5 0 10.3-7.4 6.5-12.7z"
/>
</svg>
</span>
</span>
<span
aria-disabled="false"
aria-label="Decrease Value"
class="ant-input-number-handler ant-input-number-handler-down"
role="button"
unselectable="on"
>
<span
aria-label="down"
class="anticon anticon-down ant-input-number-handler-down-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="down"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
/>
</svg>
</span>
</span>
</div>
<div
class="ant-input-number-input-wrap"
>
<input
autocomplete="off"
class="ant-input-number-input"
id="offset_1"
placeholder="offsetTop"
role="spinbutton"
step="1"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
`;
exports[`renders ./components/watermark/demo/image.tsx correctly 1`] = `
<div
style="position:relative"
>
<div
style="height:500px"
/>
</div>
`;
exports[`renders ./components/watermark/demo/multi-line.tsx correctly 1`] = `
<div
style="position:relative"
>
<div
style="height:500px"
/>
</div>
`;

View File

@ -0,0 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Watermark Image watermark snapshot 1`] = `
<div>
<div
style="position: relative;"
>
<div
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; background-size: 320px; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00');"
/>
</div>
</div>
`;
exports[`Watermark MutationObserver should work properly 1`] = `
<div>
<div
class="watermark"
style="position: relative;"
/>
</div>
`;
exports[`Watermark Observe the modification of style 1`] = `
<div>
<div
class="watermark"
style="position: relative;"
>
<div
style=""
/>
</div>
</div>
`;
exports[`Watermark The offset should be correct 1`] = `
<div>
<div
class="watermark"
style="position: relative;"
>
<div
style="z-index: 9; position: absolute; left: 100px; top: 100px; width: calc(100% - 100px); height: calc(100% - 100px); background-size: 214px; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00');"
/>
</div>
</div>
`;
exports[`Watermark The watermark should render successfully 1`] = `
<div>
<div
class="watermark"
style="position: relative;"
>
<div
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; background-size: 210px; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00');"
/>
</div>
</div>
`;
exports[`Watermark rtl render component should be rendered correctly in RTL direction 1`] = `
<div
style="position: relative;"
>
<div
style="z-index: 9; position: absolute; left: 0; top: 0; width: 100%; height: 100%; background-size: 200px; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00');"
/>
</div>
`;

View File

@ -0,0 +1,3 @@
import { extendTest } from '../../../tests/shared/demoTest';
extendTest('watermark');

View File

@ -0,0 +1,3 @@
import demoTest from '../../../tests/shared/demoTest';
demoTest('watermark');

View File

@ -0,0 +1,5 @@
import { imageDemoTest } from '../../../tests/shared/imageTest';
describe('Watermark image', () => {
imageDemoTest('watermark');
});

View File

@ -0,0 +1,69 @@
import React from 'react';
import Watermark from '..';
import mountTest from '../../../tests/shared/mountTest';
import rtlTest from '../../../tests/shared/rtlTest';
import { render } from '../../../tests/utils';
describe('Watermark', () => {
mountTest(Watermark);
rtlTest(Watermark);
const mockSrcSet = jest.spyOn(Image.prototype, 'src', 'set');
beforeAll(() => {
mockSrcSet.mockImplementation(function fn() {
if (this.onload) {
this.onload();
}
});
});
afterAll(() => {
mockSrcSet.mockRestore();
});
it('The watermark should render successfully', () => {
const { container } = render(<Watermark className="watermark" content="Ant Design" />);
expect(container.querySelector('.watermark div')).toBeTruthy();
expect(container).toMatchSnapshot();
});
it('The offset should be correct', () => {
const { container } = render(
<Watermark
className="watermark"
offset={[200, 200]}
content={['Ant Design', 'Ant Design Pro']}
/>,
);
const target = container.querySelector('.watermark div') as HTMLDivElement;
expect(target.style.left).toBe('100px');
expect(target.style.top).toBe('100px');
expect(target.style.width).toBe('calc(100% - 100px)');
expect(target.style.height).toBe('calc(100% - 100px)');
expect(container).toMatchSnapshot();
});
it('Image watermark snapshot', () => {
const { container } = render(
<Watermark image="https://gw.alipayobjects.com/zos/bmw-prod/59a18171-ae17-4fc5-93a0-2645f64a3aca.svg" />,
);
expect(container).toMatchSnapshot();
});
it('MutationObserver should work properly', () => {
const { container } = render(<Watermark className="watermark" content="MutationObserver" />);
const target = container.querySelector('.watermark div') as HTMLDivElement;
target.remove();
expect(container).toMatchSnapshot();
});
it('Observe the modification of style', () => {
const { container } = render(
<Watermark offset={[-200, -200]} className="watermark" content="MutationObserver" />,
);
const target = container.querySelector('.watermark div') as HTMLDivElement;
target.setAttribute('style', '');
expect(container).toMatchSnapshot();
});
});

View File

@ -0,0 +1,7 @@
## zh-CN
最简单的用法。
## en-US
The most basic usage.

View File

@ -0,0 +1,10 @@
import React from 'react';
import { Watermark } from 'antd';
const App: React.FC = () => (
<Watermark content="Ant Design">
<div style={{ height: 500 }} />
</Watermark>
);
export default App;

View File

@ -0,0 +1,7 @@
## zh-CN
通过自定义参数配置预览水印效果。
## en-US
Preview the watermark effect by configuring custom parameters.

View File

@ -0,0 +1,166 @@
import React, { useMemo, useState } from 'react';
import { Watermark, Popover, Typography, Form, Input, Slider, Space, InputNumber } from 'antd';
import { SketchPicker } from 'react-color';
import type { RGBColor } from 'react-color';
const { Paragraph } = Typography;
interface ColorPickerProps {
value?: RGBColor;
onChange?: (value: RGBColor) => void;
}
const ColorPicker: React.FC<ColorPickerProps> = ({ value, onChange }) => {
const switchStyle = {
padding: 4,
background: '#fff',
borderRadius: 2,
border: '1px solid #dedede',
display: 'inline-block',
cursor: 'pointer',
};
const colorStyle = {
width: 36,
height: 14,
borderRadius: 2,
background: `rgba(${value?.r}, ${value?.g}, ${value?.b}, ${value?.a})`,
};
return (
<Popover
trigger="click"
placement="bottomLeft"
overlayInnerStyle={{ padding: 0 }}
content={<SketchPicker color={value} onChange={(color) => onChange?.(color.rgb)} />}
>
<div style={switchStyle}>
<div style={colorStyle} />
</div>
</Popover>
);
};
const App: React.FC = () => {
const [form] = Form.useForm();
const [config, setConfig] = useState({
content: 'Ant Design',
color: { r: 0, g: 0, b: 0, a: 0.15 },
fontSize: 16,
zIndex: 11,
rotate: -22,
gap: [200, 200] as [number, number],
offset: undefined,
});
const { content, color, fontSize, zIndex, rotate, gap, offset } = config;
const watermarkProps = useMemo(
() => ({
content,
font: {
color: `rgba(${color.r},${color.g},${color.b},${color.a})`,
fontSize,
},
zIndex,
rotate,
gap,
offset,
}),
[config],
);
return (
<div style={{ display: 'flex' }}>
<Watermark {...watermarkProps}>
<Typography>
<Paragraph>
The light-speed iteration of the digital world makes products more complex. However,
human consciousness and attention resources are limited. Facing this design
contradiction, the pursuit of natural interaction will be the consistent direction of
Ant Design.
</Paragraph>
<Paragraph>
Natural user cognition: According to cognitive psychology, about 80% of external
information is obtained through visual channels. The most important visual elements in
the interface design, including layout, colors, illustrations, icons, etc., should fully
absorb the laws of nature, thereby reducing the user&apos;s cognitive cost and bringing
authentic and smooth feelings. In some scenarios, opportunely adding other sensory
channels such as hearing, touch can create a richer and more natural product experience.
</Paragraph>
<Paragraph>
Natural user behavior: In the interaction with the system, the designer should fully
understand the relationship between users, system roles, and task objectives, and also
contextually organize system functions and services. At the same time, a series of
methods such as behavior analysis, artificial intelligence and sensors could be applied
to assist users to make effective decisions and reduce extra operations of users, to
save users&apos; mental and physical resources and make human-computer interaction more
natural.
</Paragraph>
</Typography>
<img
style={{
zIndex: 10,
width: '100%',
maxWidth: 800,
position: 'relative',
}}
src="https://gw.alipayobjects.com/mdn/rms_08e378/afts/img/A*zx7LTI_ECSAAAAAAAAAAAABkARQnAQ"
alt="示例图片"
/>
</Watermark>
<Form
style={{
width: 280,
flexShrink: 0,
borderLeft: '1px solid #eee',
paddingLeft: 20,
marginLeft: 20,
}}
form={form}
layout="vertical"
initialValues={config}
onValuesChange={(_, values) => {
setConfig(values);
}}
>
<Form.Item name="content" label="Content">
<Input placeholder="请输入" />
</Form.Item>
<Form.Item name="color" label="Color">
<ColorPicker />
</Form.Item>
<Form.Item name="fontSize" label="FontSize">
<Slider step={1} min={0} max={100} />
</Form.Item>
<Form.Item name="zIndex" label="zIndex">
<Slider step={1} min={0} max={100} />
</Form.Item>
<Form.Item name="rotate" label="Rotate">
<Slider step={1} min={-180} max={180} />
</Form.Item>
<Form.Item label="Gap" style={{ marginBottom: 0 }}>
<Space style={{ display: 'flex' }} align="baseline">
<Form.Item name={['gap', 0]}>
<InputNumber placeholder="gapX" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['gap', 1]}>
<InputNumber placeholder="gapY" style={{ width: '100%' }} />
</Form.Item>
</Space>
</Form.Item>
<Form.Item label="Offset" style={{ marginBottom: 0 }}>
<Space style={{ display: 'flex' }} align="baseline">
<Form.Item name={['offset', 0]}>
<InputNumber placeholder="offsetLeft" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['offset', 1]}>
<InputNumber placeholder="offsetTop" style={{ width: '100%' }} />
</Form.Item>
</Space>
</Form.Item>
</Form>
</div>
);
};
export default App;

View File

@ -0,0 +1,7 @@
## zh-CN
通过 `image` 指定图片地址。为保证图片高清且不被拉伸,请设置 width 和 height, 并上传至少两倍的宽高的 logo 图片地址。
## en-US
Specify the image address via 'image'. To ensure that the image is high definition and not stretched, set the width and height, and upload at least twice the width and height of the logo image address.

View File

@ -0,0 +1,14 @@
import React from 'react';
import { Watermark } from 'antd';
const App: React.FC = () => (
<Watermark
height={30}
width={130}
image="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*lkAoRbywo0oAAAAAAAAAAAAADrJ8AQ/original"
>
<div style={{ height: 500 }} />
</Watermark>
);
export default App;

View File

@ -0,0 +1,7 @@
## zh-CN
通过 `content` 设置 字符串数组 指定多行文字水印内容。
## en-US
Use 'content' to set a string array to specify multi-line text watermark content.

View File

@ -0,0 +1,10 @@
import React from 'react';
import { Watermark } from 'antd';
const App: React.FC = () => (
<Watermark content={['Ant Design', 'Happy Working']}>
<div style={{ height: 500 }} />
</Watermark>
);
export default App;

View File

@ -0,0 +1,50 @@
---
category: Components
group: Other
title: Watermark
cover: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*wr1ISY50SyYAAAAAAAAAAAAADrJ8AQ/original
demo:
cols: 1
---
Add specific text or patterns to the page.
## When To Use
- Use when the page needs to be watermarked to identify the copyright.
- Suitable for preventing information theft.
## Examples
<!-- prettier-ignore -->
<code src="./demo/basic.tsx">Basic</code>
<code src="./demo/multi-line.tsx">Multi-line watermark</code>
<code src="./demo/image.tsx">Image watermark</code>
<code src="./demo/custom.tsx">Custom configuration</code>
## API
### Watermark
| Property | Description | Type | Default | Version |
| --- | --- | --- | --- | --- |
| width | The width of the watermark, the default value of `content` is its own width | number | 120 | |
| height | The height of the watermark, the default value of `content` is its own height | number | 64 | |
| rotate | When the watermark is drawn, the rotation Angle, unit `°` | number | -22 | |
| zIndex | The z-index of the appended watermark element | number | 9 | |
| image | Image source, it is recommended to export 2x or 3x image, high priority | string | - | |
| content | Watermark text content | string \| string[] | - | |
| font | Text style | [Font](#Font) | [Font](#Font) | |
| gap | The spacing between watermarks | \[number, number\] | \[200, 200\] | |
| offset | The offset of the watermark from the upper left corner of the container. The default is `gap/2` | \[number, number\] | \[gap\[0\]/2, gap\[1\]/2\] | |
### Font
<!-- prettier-ignore -->
| 参数 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | --- |
| color | font color | string | rgba(0,0,0,.15) | |
| fontSize | font size | number | 16 | |
| fontWeight | font weight | `normal` \| `light` \| `weight` \| number | normal | |
| fontFamily | font family | string | sans-serif | |
| fontStyle | font style | `none` \| `normal` \| `italic` \| `oblique` | normal | |

View File

@ -0,0 +1,262 @@
import React, { useEffect, useRef } from 'react';
import useMutationObserver from './useMutationObserver';
const FontGap = 3;
const getStyleStr = (style: Record<string, string | number>): string => {
const styleArr = Object.keys(style).map((item) => {
const key = item.replace(/([A-Z])/g, '-$1').toLowerCase();
return `${key}: ${style[item]};`;
});
return styleArr.join(' ');
};
export interface WatermarkProps {
zIndex?: number;
rotate?: number;
width?: number;
height?: number;
image?: string;
content?: string | string[];
font?: {
color?: string;
fontSize?: number | string;
fontWeight?: 'normal' | 'light' | 'weight' | number;
fontStyle?: 'none' | 'normal' | 'italic' | 'oblique';
fontFamily?: string;
};
style?: React.CSSProperties;
className?: string;
gap?: [number, number];
offset?: [number, number];
children?: React.ReactNode;
}
const Watermark: React.FC<WatermarkProps> = (props) => {
const {
/**
* The antd content layer zIndex is basically below 10
* https://github.com/ant-design/ant-design/blob/6192403b2ce517c017f9e58a32d58774921c10cd/components/style/themes/default.less#L335
*/
zIndex = 9,
rotate = -22,
width,
height,
image,
content,
font = {},
style,
className,
gap = [200, 200],
offset,
children,
} = props;
const {
color = 'rgba(0,0,0,.15)',
fontSize = 16,
fontWeight = 'normal',
fontStyle = 'normal',
fontFamily = 'sans-serif',
} = font;
const [gapX, gapY] = gap;
const gapXCenter = gapX / 2;
const gapYCenter = gapY / 2;
const offsetLeft = offset?.[0] ?? gapXCenter;
const offsetTop = offset?.[1] ?? gapYCenter;
const getMarkStyle = (markWidth: number) => {
const markStyle: React.CSSProperties = {
zIndex,
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
backgroundSize: `${gapX + markWidth}px`,
pointerEvents: 'none',
backgroundRepeat: 'repeat',
};
/** Calculate the style of the offset */
let positionLeft = offsetLeft - gapXCenter;
let positionTop = offsetTop - gapYCenter;
if (positionLeft > 0) {
markStyle.left = `${positionLeft}px`;
markStyle.width = `calc(100% - ${positionLeft}px)`;
positionLeft = 0;
}
if (positionTop > 0) {
markStyle.top = `${positionTop}px`;
markStyle.height = `calc(100% - ${positionTop}px)`;
positionTop = 0;
}
markStyle.backgroundPosition = `${positionLeft}px ${positionTop}px`;
return markStyle;
};
const containerRef = useRef<HTMLDivElement>(null);
const watermarkRef = useRef<HTMLDivElement>();
const { createObserver, destroyObserver } = useMutationObserver();
const destroyWatermark = () => {
if (watermarkRef.current) {
watermarkRef.current.remove();
watermarkRef.current = undefined;
}
};
const reRendering = (mutation: MutationRecord) => {
let flag = false;
// Whether to delete the watermark node
if (mutation.removedNodes.length) {
mutation.removedNodes.forEach((node) => {
if (node === watermarkRef.current) {
flag = true;
}
});
}
// Whether the watermark dom property value has been modified
if (mutation.type === 'attributes' && mutation.target === watermarkRef.current) {
flag = true;
}
return flag;
};
const appendWatermark = (base64Url: string, markWidth: number) => {
if (containerRef.current && watermarkRef.current) {
destroyObserver();
watermarkRef.current.setAttribute(
'style',
getStyleStr({
...getMarkStyle(markWidth),
backgroundImage: `url('${base64Url}')`,
}),
);
containerRef.current?.append(watermarkRef.current);
createObserver(containerRef.current, (mutations) => {
mutations.forEach((mutation) => {
if (reRendering(mutation)) {
destroyWatermark();
// eslint-disable-next-line @typescript-eslint/no-use-before-define
renderWatermark();
}
});
});
}
};
/**
* 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 as string).width);
defaultWidth = Math.ceil(Math.max(...widths));
defaultHeight = Number(fontSize) * contents.length + (contents.length - 1) * FontGap;
}
return [width ?? defaultWidth, height ?? defaultHeight];
};
const renderWatermark = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx) {
if (!watermarkRef.current) {
watermarkRef.current = document.createElement('div');
}
const ratio = window.devicePixelRatio || 1;
const [markWidth, markHeight] = getMarkSize(ctx);
const canvasWidth = `${(gapX + markWidth) * ratio}px`;
const canvasHeight = `${(gapY + markHeight) * ratio}px`;
canvas.setAttribute('width', canvasWidth);
canvas.setAttribute('height', canvasHeight);
const mergedMarkWidth = markWidth * ratio;
const mergedMarkHeight = markHeight * ratio;
const mergedGapXCenter = (gapX * ratio) / 2;
const mergedGapYCenter = (gapY * ratio) / 2;
/** Rotate with the canvas as the center point */
const centerX = (mergedMarkWidth + gapX * ratio) / 2;
const centerY = (mergedMarkHeight + gapY * ratio) / 2;
ctx.translate(centerX, centerY);
ctx.rotate((Math.PI / 180) * Number(rotate));
ctx.translate(-centerX, -centerY);
if (image) {
const img = new Image();
img.onload = () => {
ctx.drawImage(img, mergedGapXCenter, mergedGapYCenter, mergedMarkWidth, mergedMarkHeight);
appendWatermark(canvas.toDataURL(), markWidth);
};
img.crossOrigin = 'anonymous';
img.referrerPolicy = 'no-referrer';
img.src = image;
} else {
const mergedFontSize = Number(fontSize) * ratio;
ctx.font = `${fontStyle} normal ${fontWeight} ${mergedFontSize}px/${mergedMarkHeight}px ${fontFamily}`;
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.translate(mergedMarkWidth / 2, 0);
if (Array.isArray(content)) {
content?.forEach((item: string, index: number) =>
ctx.fillText(
item,
mergedGapXCenter,
mergedGapYCenter + index * (mergedFontSize + FontGap * ratio),
),
);
} else {
ctx.fillText(content ?? '', mergedGapXCenter, mergedGapYCenter);
}
appendWatermark(canvas.toDataURL(), markWidth);
}
}
};
useEffect(() => {
renderWatermark();
}, [
rotate,
zIndex,
width,
height,
image,
content,
color,
fontSize,
fontWeight,
fontStyle,
fontFamily,
gapX,
gapY,
offsetLeft,
offsetTop,
]);
return (
<div
ref={containerRef}
style={{
position: 'relative',
...style,
}}
className={className}
>
{children}
</div>
);
};
export default Watermark;

View File

@ -0,0 +1,51 @@
---
category: Components
subtitle: 水印
group: 其他
title: Watermark
cover: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*wr1ISY50SyYAAAAAAAAAAAAADrJ8AQ/original
demo:
cols: 1
---
给页面的某个区域加上水印。
## 何时使用
- 页面需要添加水印标识版权时使用。
- 适用于防止信息盗用。
## 代码演示
<!-- prettier-ignore -->
<code src="./demo/basic.tsx">基本</code>
<code src="./demo/multi-line.tsx">多行水印</code>
<code src="./demo/image.tsx">图片水印</code>
<code src="./demo/custom.tsx">自定义配置</code>
## API
### Watermark
| 参数 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | --- |
| width | 水印的宽度,`content` 的默认值为自身的宽度 | number | 120 | |
| height | 水印的高度,`content` 的默认值为自身的高度 | number | 64 | |
| rotate | 水印绘制时,旋转的角度,单位 `°` | number | -22 | |
| zIndex | 追加的水印元素的 z-index | number | 9 | |
| image | 图片源,建议导出 2 倍或 3 倍图,优先级高 | string | - | |
| content | 水印文字内容 | string \| string[] | - | |
| font | 文字样式 | [Font](#Font) | [Font](#Font) | |
| gap | 水印之间的间距 | \[number, number\] | \[200, 200\] | |
| offset | 水印距离容器左上角的偏移量,默认为 `gap/2` | \[number, number\] | \[gap\[0\]/2, gap\[1\]/2\] | |
### Font
<!-- prettier-ignore -->
| 参数 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | --- |
| color | 字体颜色 | string | rgba(0,0,0,.15) | |
| fontSize | 字体大小 | number | 16 | |
| fontWeight | 字体粗细 | `normal` \| `light` \| `weight` \| number | normal | |
| fontFamily | 字体类型 | string | sans-serif | |
| fontStyle | 字体样式 | `none` \| `normal` \| `italic` \| `oblique` | normal | |

View File

@ -0,0 +1,32 @@
import { useEffect, useRef } from 'react';
export default function useMutationObserver() {
const instance = useRef<MutationObserver>();
const destroyObserver = () => {
if (instance.current) {
instance.current.takeRecords();
instance.current.disconnect();
instance.current = undefined;
}
};
const createObserver = (target: Node, callback: MutationCallback) => {
if (MutationObserver) {
destroyObserver();
instance.current = new MutationObserver(callback);
instance.current.observe(target, {
childList: true,
subtree: true,
attributeFilter: ['style', 'class'],
});
}
};
useEffect(() => () => destroyObserver(), []);
return {
createObserver,
destroyObserver,
};
}

View File

@ -243,6 +243,7 @@
"isomorphic-fetch": "^3.0.0",
"jest": "^29.0.0",
"jest-axe": "^7.0.0",
"jest-canvas-mock": "^2.4.0",
"jest-environment-jsdom": "^29.0.1",
"jest-environment-node": "^29.0.0",
"jest-image-snapshot": "^6.0.0",
@ -312,7 +313,7 @@
"size-limit": [
{
"path": "./dist/antd.min.js",
"limit": "381 KiB"
"limit": "382 KiB"
},
{
"path": "./dist/antd-with-locales.min.js",
@ -322,7 +323,7 @@
"bundlesize": [
{
"path": "./dist/antd.min.js",
"maxSize": "381 kB"
"maxSize": "382 kB"
}
],
"tnpm": {

View File

@ -67,6 +67,7 @@ exports[`antd dist files exports modules correctly 1`] = `
"TreeSelect",
"Typography",
"Upload",
"Watermark",
"message",
"notification",
"theme",