mirror of
https://github.com/ant-design/ant-design.git
synced 2025-08-06 07:56:28 +08:00
feat: The watermark is staggered by default (#39464)
* chore: watermark add utils * feat: Support watermark interleaved layout * docs: add watermark docs * docs: add watermark demo * test: add watermark test * test: add watermark snapshot * feat: The watermark is staggered by default
This commit is contained in:
parent
ff63068e47
commit
ec76041584
@ -904,14 +904,14 @@ exports[`renders ./components/watermark/demo/custom.tsx extend context correctly
|
||||
class="ant-input-number-input-wrap"
|
||||
>
|
||||
<input
|
||||
aria-valuenow="200"
|
||||
aria-valuenow="100"
|
||||
autocomplete="off"
|
||||
class="ant-input-number-input"
|
||||
id="gap_0"
|
||||
placeholder="gapX"
|
||||
role="spinbutton"
|
||||
step="1"
|
||||
value="200"
|
||||
value="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -1005,14 +1005,14 @@ exports[`renders ./components/watermark/demo/custom.tsx extend context correctly
|
||||
class="ant-input-number-input-wrap"
|
||||
>
|
||||
<input
|
||||
aria-valuenow="200"
|
||||
aria-valuenow="100"
|
||||
autocomplete="off"
|
||||
class="ant-input-number-input"
|
||||
id="gap_1"
|
||||
placeholder="gapY"
|
||||
role="spinbutton"
|
||||
step="1"
|
||||
value="200"
|
||||
value="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -402,14 +402,14 @@ exports[`renders ./components/watermark/demo/custom.tsx correctly 1`] = `
|
||||
class="ant-input-number-input-wrap"
|
||||
>
|
||||
<input
|
||||
aria-valuenow="200"
|
||||
aria-valuenow="100"
|
||||
autocomplete="off"
|
||||
class="ant-input-number-input"
|
||||
id="gap_0"
|
||||
placeholder="gapX"
|
||||
role="spinbutton"
|
||||
step="1"
|
||||
value="200"
|
||||
value="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -503,14 +503,14 @@ exports[`renders ./components/watermark/demo/custom.tsx correctly 1`] = `
|
||||
class="ant-input-number-input-wrap"
|
||||
>
|
||||
<input
|
||||
aria-valuenow="200"
|
||||
aria-valuenow="100"
|
||||
autocomplete="off"
|
||||
class="ant-input-number-input"
|
||||
id="gap_1"
|
||||
placeholder="gapY"
|
||||
role="spinbutton"
|
||||
step="1"
|
||||
value="200"
|
||||
value="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -6,7 +6,20 @@ exports[`Watermark Image watermark snapshot 1`] = `
|
||||
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');"
|
||||
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;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Watermark Interleaved watermark backgroundSize is correct 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="watermark"
|
||||
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;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -19,7 +32,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%; background-size: 216px; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00');"
|
||||
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;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -32,7 +45,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%; background-size: 216px; pointer-events: none; background-repeat: repeat; background-position: -300px -300px; background-image: url('data:image/png;base64,00');"
|
||||
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;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -45,7 +58,7 @@ exports[`Watermark The offset should be correct 1`] = `
|
||||
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');"
|
||||
style="z-index: 9; position: absolute; left: 150px; top: 150px; width: calc(100% - 150px); height: calc(100% - 150px); pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00'); background-size: 228px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -58,7 +71,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%; background-size: 210px; pointer-events: none; background-repeat: repeat; background-position: 0px 0px; background-image: url('data:image/png;base64,00');"
|
||||
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;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -69,7 +82,7 @@ exports[`Watermark rtl render component should be rendered correctly in RTL dire
|
||||
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');"
|
||||
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: 200px;"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
@ -35,10 +35,25 @@ describe('Watermark', () => {
|
||||
/>,
|
||||
);
|
||||
const target = container.querySelector<HTMLDivElement>('.watermark div');
|
||||
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(target?.style.left).toBe('150px');
|
||||
expect(target?.style.top).toBe('150px');
|
||||
expect(target?.style.width).toBe('calc(100% - 150px)');
|
||||
expect(target?.style.height).toBe('calc(100% - 150px)');
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Interleaved watermark backgroundSize is correct', () => {
|
||||
const { container } = render(
|
||||
<Watermark
|
||||
className="watermark"
|
||||
width={200}
|
||||
height={200}
|
||||
content="Ant Design"
|
||||
gap={[100, 100]}
|
||||
/>,
|
||||
);
|
||||
const target = container.querySelector<HTMLDivElement>('.watermark div');
|
||||
expect(target?.style.backgroundSize).toBe('600px');
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
@ -49,7 +49,7 @@ const App: React.FC = () => {
|
||||
fontSize: 16,
|
||||
zIndex: 11,
|
||||
rotate: -22,
|
||||
gap: [200, 200] as [number, number],
|
||||
gap: [100, 100] as [number, number],
|
||||
offset: undefined,
|
||||
});
|
||||
const { content, color, fontSize, zIndex, rotate, gap, offset } = config;
|
||||
|
@ -35,7 +35,7 @@ Add specific text or patterns to the page.
|
||||
| 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\] | |
|
||||
| gap | The spacing between watermarks | \[number, number\] | \[100, 100\] | |
|
||||
| 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
|
||||
|
@ -1,16 +1,14 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import useMutationObserver from './useMutationObserver';
|
||||
import { getStyleStr, getPixelRatio, rotateWatermark } from './utils';
|
||||
|
||||
/**
|
||||
* Base size of the canvas, 1 for parallel layout and 2 for alternate layout
|
||||
* Only alternate layout is currently supported
|
||||
*/
|
||||
const BaseSize = 2;
|
||||
const FontGap = 3;
|
||||
|
||||
const getStyleStr = (style: React.CSSProperties): string => {
|
||||
const styleArr = Object.keys(style).map((item: keyof React.CSSProperties) => {
|
||||
const key = item.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
return `${key}: ${style[item]};`;
|
||||
});
|
||||
return styleArr.join(' ');
|
||||
};
|
||||
|
||||
export interface WatermarkProps {
|
||||
zIndex?: number;
|
||||
rotate?: number;
|
||||
@ -47,7 +45,7 @@ const Watermark: React.FC<WatermarkProps> = (props) => {
|
||||
font = {},
|
||||
style,
|
||||
className,
|
||||
gap = [200, 200],
|
||||
gap = [100, 100],
|
||||
offset,
|
||||
children,
|
||||
} = props;
|
||||
@ -66,7 +64,7 @@ const Watermark: React.FC<WatermarkProps> = (props) => {
|
||||
const offsetLeft = offset?.[0] ?? gapXCenter;
|
||||
const offsetTop = offset?.[1] ?? gapYCenter;
|
||||
|
||||
const getMarkStyle = (markWidth: number) => {
|
||||
const getMarkStyle = () => {
|
||||
const markStyle: React.CSSProperties = {
|
||||
zIndex,
|
||||
position: 'absolute',
|
||||
@ -74,7 +72,6 @@ const Watermark: React.FC<WatermarkProps> = (props) => {
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundSize: `${gapX + markWidth}px`,
|
||||
pointerEvents: 'none',
|
||||
backgroundRepeat: 'repeat',
|
||||
};
|
||||
@ -99,7 +96,7 @@ const Watermark: React.FC<WatermarkProps> = (props) => {
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const watermarkRef = useRef<HTMLDivElement>();
|
||||
const { createObserver, destroyObserver } = useMutationObserver();
|
||||
const { createObserver, destroyObserver, reRendering } = useMutationObserver();
|
||||
|
||||
const destroyWatermark = () => {
|
||||
if (watermarkRef.current) {
|
||||
@ -108,33 +105,21 @@ const Watermark: React.FC<WatermarkProps> = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const reRendering = (mutation: MutationRecord) => {
|
||||
let flag = false;
|
||||
// Whether to delete the watermark node
|
||||
if (mutation.removedNodes.length) {
|
||||
flag = Array.from(mutation.removedNodes).some((node) => node === watermarkRef.current);
|
||||
}
|
||||
// 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),
|
||||
...getMarkStyle(),
|
||||
backgroundImage: `url('${base64Url}')`,
|
||||
backgroundSize: `${(gapX + markWidth) * BaseSize}px`,
|
||||
}),
|
||||
);
|
||||
containerRef.current?.append(watermarkRef.current);
|
||||
createObserver(containerRef.current, (mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (reRendering(mutation)) {
|
||||
if (reRendering(mutation, watermarkRef.current)) {
|
||||
destroyWatermark();
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
renderWatermark();
|
||||
@ -148,7 +133,7 @@ const Watermark: React.FC<WatermarkProps> = (props) => {
|
||||
* 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): readonly [number, number] => {
|
||||
const getMarkSize = (ctx: CanvasRenderingContext2D) => {
|
||||
let defaultWidth = 120;
|
||||
let defaultHeight = 64;
|
||||
if (!image && ctx.measureText) {
|
||||
@ -161,6 +146,26 @@ const Watermark: React.FC<WatermarkProps> = (props) => {
|
||||
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 renderWatermark = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
@ -170,49 +175,47 @@ const Watermark: React.FC<WatermarkProps> = (props) => {
|
||||
watermarkRef.current = document.createElement('div');
|
||||
}
|
||||
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const ratio = getPixelRatio();
|
||||
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 canvasWidth = (gapX + markWidth) * ratio;
|
||||
const canvasHeight = (gapY + markHeight) * ratio;
|
||||
canvas.setAttribute('width', `${canvasWidth * BaseSize}px`);
|
||||
canvas.setAttribute('height', `${canvasHeight * BaseSize}px`);
|
||||
|
||||
const mergedMarkWidth = markWidth * ratio;
|
||||
const mergedMarkHeight = markHeight * ratio;
|
||||
const mergedGapXCenter = (gapX * ratio) / 2;
|
||||
const mergedGapYCenter = (gapY * ratio) / 2;
|
||||
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;
|
||||
|
||||
/** 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);
|
||||
ctx.save();
|
||||
rotateWatermark(ctx, rotateX, rotateY, rotate);
|
||||
|
||||
if (image) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, mergedGapXCenter, mergedGapYCenter, mergedMarkWidth, mergedMarkHeight);
|
||||
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);
|
||||
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);
|
||||
const contents = Array.isArray(content) ? content : [content];
|
||||
contents?.forEach((item, index) => {
|
||||
ctx.fillText(
|
||||
item ?? '',
|
||||
mergedGapXCenter,
|
||||
mergedGapYCenter + index * (mergedFontSize + FontGap * ratio),
|
||||
);
|
||||
});
|
||||
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);
|
||||
appendWatermark(canvas.toDataURL(), markWidth);
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ demo:
|
||||
| image | 图片源,建议导出 2 倍或 3 倍图,优先级高 | string | - | |
|
||||
| content | 水印文字内容 | string \| string[] | - | |
|
||||
| font | 文字样式 | [Font](#Font) | [Font](#Font) | |
|
||||
| gap | 水印之间的间距 | \[number, number\] | \[200, 200\] | |
|
||||
| gap | 水印之间的间距 | \[number, number\] | \[100, 100\] | |
|
||||
| offset | 水印距离容器左上角的偏移量,默认为 `gap/2` | \[number, number\] | \[gap\[0\]/2, gap\[1\]/2\] | |
|
||||
|
||||
### Font
|
||||
|
@ -25,5 +25,18 @@ export default function useMutationObserver() {
|
||||
|
||||
useEffect(() => destroyObserver, []);
|
||||
|
||||
return { createObserver, destroyObserver };
|
||||
const reRendering = (mutation: MutationRecord, watermarkElement?: HTMLElement) => {
|
||||
let flag = false;
|
||||
// Whether to delete the watermark node
|
||||
if (mutation.removedNodes.length) {
|
||||
flag = Array.from(mutation.removedNodes).some((node) => node === watermarkElement);
|
||||
}
|
||||
// Whether the watermark dom property value has been modified
|
||||
if (mutation.type === 'attributes' && mutation.target === watermarkElement) {
|
||||
flag = true;
|
||||
}
|
||||
return flag;
|
||||
};
|
||||
|
||||
return { createObserver, destroyObserver, reRendering };
|
||||
}
|
||||
|
27
components/watermark/utils.ts
Normal file
27
components/watermark/utils.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/** converting camel-cased strings to be lowercase and link it with Separato */
|
||||
export function toLowercaseSeparator(key: string) {
|
||||
return key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
}
|
||||
|
||||
export function getStyleStr(style: React.CSSProperties): string {
|
||||
return Object.keys(style)
|
||||
.map((key: keyof React.CSSProperties) => `${toLowercaseSeparator(key)}: ${style[key]};`)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/** Returns the ratio of the device's physical pixel resolution to the css pixel resolution */
|
||||
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);
|
||||
}
|
Loading…
Reference in New Issue
Block a user