mirror of
https://github.com/ant-design/ant-design.git
synced 2024-11-24 02:59:58 +08:00
feat: Watermark support nest Modal & Drawer (#44104)
* docs: add demo * refactor: init * refactor: of it * refactor: simple content * chore: unique func * chore: refactor * chore: support modal watermark * feat: support nest watermark * test: add test case * chore: fix lint * chore: bump rc-image * test: add test case * refactor: use same func
This commit is contained in:
parent
4fe27ba027
commit
8a3870fc31
@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import useEvent from 'rc-util/lib/hooks/useEvent';
|
||||
import { useEvent } from 'rc-util';
|
||||
import raf from 'rc-util/lib/raf';
|
||||
import showWaveEffect from './WaveEffect';
|
||||
import { ConfigContext } from '../../config-provider';
|
||||
|
@ -1,5 +1,5 @@
|
||||
import classNames from 'classnames';
|
||||
import useEvent from 'rc-util/lib/hooks/useEvent';
|
||||
import { useEvent } from 'rc-util';
|
||||
import * as React from 'react';
|
||||
import scrollIntoView from 'scroll-into-view-if-needed';
|
||||
|
||||
|
@ -16,6 +16,7 @@ import DrawerPanel from './DrawerPanel';
|
||||
// CSSINJS
|
||||
import { NoCompactStyle } from '../space/Compact';
|
||||
import useStyle from './style';
|
||||
import { usePanelRef } from '../watermark/context';
|
||||
|
||||
const SizeTypes = ['default', 'large'] as const;
|
||||
type sizeType = typeof SizeTypes[number];
|
||||
@ -137,6 +138,10 @@ const Drawer: React.FC<DrawerProps> & {
|
||||
motionDeadline: 500,
|
||||
});
|
||||
|
||||
// ============================ Refs ============================
|
||||
// Select `ant-modal-content` by `panelRef`
|
||||
const panelRef = usePanelRef();
|
||||
|
||||
// =========================== Render ===========================
|
||||
return wrapSSR(
|
||||
<NoCompactStyle>
|
||||
@ -157,6 +162,7 @@ const Drawer: React.FC<DrawerProps> & {
|
||||
rootClassName={drawerClassName}
|
||||
getContainer={getContainer}
|
||||
afterOpenChange={afterOpenChange ?? afterVisibleChange}
|
||||
panelRef={panelRef}
|
||||
>
|
||||
<DrawerPanel prefixCls={prefixCls} {...rest} onClose={onClose} />
|
||||
</RcDrawer>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import RightOutlined from '@ant-design/icons/RightOutlined';
|
||||
import classNames from 'classnames';
|
||||
import RcDropdown from 'rc-dropdown';
|
||||
import useEvent from 'rc-util/lib/hooks/useEvent';
|
||||
import { useEvent } from 'rc-util';
|
||||
import useMergedState from 'rc-util/lib/hooks/useMergedState';
|
||||
import omit from 'rc-util/lib/omit';
|
||||
import * as React from 'react';
|
||||
@ -28,7 +28,7 @@ const Placements = [
|
||||
'bottom',
|
||||
] as const;
|
||||
|
||||
type Placement = (typeof Placements)[number];
|
||||
type Placement = typeof Placements[number];
|
||||
type DropdownPlacement = Exclude<Placement, 'topCenter' | 'bottomCenter'>;
|
||||
|
||||
type OverlayFunc = () => React.ReactElement;
|
||||
|
@ -2,7 +2,7 @@ import EllipsisOutlined from '@ant-design/icons/EllipsisOutlined';
|
||||
import classNames from 'classnames';
|
||||
import type { MenuProps as RcMenuProps, MenuRef as RcMenuRef } from 'rc-menu';
|
||||
import RcMenu from 'rc-menu';
|
||||
import useEvent from 'rc-util/lib/hooks/useEvent';
|
||||
import { useEvent } from 'rc-util';
|
||||
import omit from 'rc-util/lib/omit';
|
||||
import * as React from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
@ -12,6 +12,7 @@ import { NoCompactStyle } from '../space/Compact';
|
||||
import type { ModalProps, MousePosition } from './interface';
|
||||
import { Footer, renderCloseIcon } from './shared';
|
||||
import useStyle from './style';
|
||||
import { usePanelRef } from '../watermark/context';
|
||||
|
||||
let mousePosition: MousePosition;
|
||||
|
||||
@ -103,6 +104,11 @@ const Modal: React.FC<ModalProps> = (props) => {
|
||||
true,
|
||||
);
|
||||
|
||||
// ============================ Refs ============================
|
||||
// Select `ant-modal-content` by `panelRef`
|
||||
const panelRef = usePanelRef(`.${prefixCls}-content`);
|
||||
|
||||
// =========================== Render ===========================
|
||||
return wrapSSR(
|
||||
<NoCompactStyle>
|
||||
<NoFormStyle status override>
|
||||
@ -124,6 +130,7 @@ const Modal: React.FC<ModalProps> = (props) => {
|
||||
maskTransitionName={getTransitionName(rootPrefixCls, 'fade', props.maskTransitionName)}
|
||||
className={classNames(hashId, className, modal?.className)}
|
||||
style={{ ...modal?.style, ...style }}
|
||||
panelRef={panelRef}
|
||||
/>
|
||||
</NoFormStyle>
|
||||
</NoCompactStyle>,
|
||||
|
@ -1233,3 +1233,47 @@ exports[`renders components/watermark/demo/multi-line.tsx extend context correct
|
||||
`;
|
||||
|
||||
exports[`renders components/watermark/demo/multi-line.tsx extend context correctly 2`] = `[]`;
|
||||
|
||||
exports[`renders components/watermark/demo/portal.tsx extend context correctly 1`] = `
|
||||
Array [
|
||||
<div
|
||||
class="ant-space ant-space-horizontal ant-space-align-center"
|
||||
>
|
||||
<div
|
||||
class="ant-space-item"
|
||||
style="margin-right: 8px;"
|
||||
>
|
||||
<button
|
||||
class="ant-btn ant-btn-default"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Show Modal
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="ant-space-item"
|
||||
>
|
||||
<button
|
||||
class="ant-btn ant-btn-default"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Show Drawer
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
<div
|
||||
class=""
|
||||
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;"
|
||||
/>
|
||||
</div>,
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`renders components/watermark/demo/portal.tsx extend context correctly 2`] = `[]`;
|
||||
|
@ -795,3 +795,41 @@ exports[`renders components/watermark/demo/multi-line.tsx correctly 1`] = `
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`renders components/watermark/demo/portal.tsx correctly 1`] = `
|
||||
Array [
|
||||
<div
|
||||
class="ant-space ant-space-horizontal ant-space-align-center"
|
||||
>
|
||||
<div
|
||||
class="ant-space-item"
|
||||
style="margin-right:8px"
|
||||
>
|
||||
<button
|
||||
class="ant-btn ant-btn-default"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Show Modal
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="ant-space-item"
|
||||
>
|
||||
<button
|
||||
class="ant-btn ant-btn-default"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Show Drawer
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
<div
|
||||
class=""
|
||||
style="position:relative"
|
||||
/>,
|
||||
]
|
||||
`;
|
||||
|
@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import Watermark from '..';
|
||||
import Modal from '../../modal';
|
||||
import Drawer from '../../drawer';
|
||||
import mountTest from '../../../tests/shared/mountTest';
|
||||
import rtlTest from '../../../tests/shared/rtlTest';
|
||||
import { render, waitFakeTimer, waitFor } from '../../../tests/utils';
|
||||
@ -94,4 +96,34 @@ describe('Watermark', () => {
|
||||
await waitFor(() => expect(target).toBeTruthy());
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('nest component', () => {
|
||||
function test(name: string, children: React.ReactNode, getWatermarkElement: () => Node) {
|
||||
it(name, async () => {
|
||||
const { rerender } = render(<Watermark className="test">{children}</Watermark>);
|
||||
await waitFakeTimer();
|
||||
|
||||
const watermark = getWatermarkElement();
|
||||
|
||||
expect(watermark).toHaveStyle({
|
||||
zIndex: '9',
|
||||
});
|
||||
|
||||
// Not crash when children removed
|
||||
rerender(<Watermark className="test" />);
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
'Modal',
|
||||
<Modal open />,
|
||||
() => document.body.querySelector('.ant-modal-content')!.lastChild!,
|
||||
);
|
||||
|
||||
test(
|
||||
'Drawer',
|
||||
<Drawer open />,
|
||||
() => document.body.querySelector('.ant-drawer-content')!.lastChild!,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
33
components/watermark/context.ts
Normal file
33
components/watermark/context.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { useEvent } from 'rc-util';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface WatermarkContextProps {
|
||||
add: (ele: HTMLElement) => void;
|
||||
remove: (ele: HTMLElement) => void;
|
||||
}
|
||||
|
||||
function voidFunc() {}
|
||||
|
||||
const WatermarkContext = React.createContext<WatermarkContextProps>({
|
||||
add: voidFunc,
|
||||
remove: voidFunc,
|
||||
});
|
||||
|
||||
export function usePanelRef(panelSelector?: string) {
|
||||
const watermark = React.useContext(WatermarkContext);
|
||||
|
||||
const panelEleRef = React.useRef<HTMLElement>();
|
||||
const panelRef = useEvent((ele: HTMLElement | null) => {
|
||||
if (ele) {
|
||||
const innerContentEle = panelSelector ? ele.querySelector<HTMLElement>(panelSelector)! : ele;
|
||||
watermark.add(innerContentEle);
|
||||
panelEleRef.current = innerContentEle;
|
||||
} else {
|
||||
watermark.remove(panelEleRef.current!);
|
||||
}
|
||||
});
|
||||
|
||||
return panelRef;
|
||||
}
|
||||
|
||||
export default WatermarkContext;
|
7
components/watermark/demo/portal.md
Normal file
7
components/watermark/demo/portal.md
Normal file
@ -0,0 +1,7 @@
|
||||
## zh-CN
|
||||
|
||||
在 Modal 与 Drawer 中使用。
|
||||
|
||||
## en-US
|
||||
|
||||
Use in Modal and Drawer.
|
50
components/watermark/demo/portal.tsx
Normal file
50
components/watermark/demo/portal.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Watermark, Modal, Drawer, Button, Space } from 'antd';
|
||||
|
||||
const placeholder = (
|
||||
<div
|
||||
style={{
|
||||
height: 300,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: 'rgba(150, 150, 150, 0.2)',
|
||||
}}
|
||||
>
|
||||
A mock height
|
||||
</div>
|
||||
);
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [showModal, setShowModal] = React.useState(false);
|
||||
const [showDrawer, setShowDrawer] = React.useState(false);
|
||||
|
||||
const closeModal = () => setShowModal(false);
|
||||
const closeDrawer = () => setShowDrawer(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space>
|
||||
<Button onClick={() => setShowModal(true)}>Show Modal</Button>
|
||||
<Button onClick={() => setShowDrawer(true)}>Show Drawer</Button>
|
||||
</Space>
|
||||
|
||||
<Watermark content="Ant Design">
|
||||
<Modal
|
||||
destroyOnClose
|
||||
open={showModal}
|
||||
title="Modal"
|
||||
onCancel={closeModal}
|
||||
onOk={closeModal}
|
||||
>
|
||||
{placeholder}
|
||||
</Modal>
|
||||
<Drawer destroyOnClose open={showDrawer} title="Drawer" onClose={closeDrawer}>
|
||||
{placeholder}
|
||||
</Drawer>
|
||||
</Watermark>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
@ -22,6 +22,7 @@ Add specific text or patterns to the page.
|
||||
<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>
|
||||
<code src="./demo/portal.tsx">Modal or Drawer</code>
|
||||
|
||||
## API
|
||||
|
||||
|
@ -1,15 +1,13 @@
|
||||
import MutateObserver from '@rc-component/mutate-observer';
|
||||
import { useMutateObserver } from '@rc-component/mutate-observer';
|
||||
import classNames from 'classnames';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { getPixelRatio, getStyleStr, reRendering, rotateWatermark } from './utils';
|
||||
import React, { useEffect } from 'react';
|
||||
import { reRendering } from './utils';
|
||||
import theme from '../theme';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
import useWatermark from './useWatermark';
|
||||
import useRafDebounce from './useRafDebounce';
|
||||
import useContent from './useContent';
|
||||
import WatermarkContext from './context';
|
||||
import type { WatermarkContextProps } from './context';
|
||||
|
||||
export interface WatermarkProps {
|
||||
zIndex?: number;
|
||||
@ -33,6 +31,14 @@ export interface WatermarkProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only return `next` when size changed.
|
||||
* This is only used for elements compare, not a shallow equal!
|
||||
*/
|
||||
function getSizeDiff<T>(prev: Set<T>, next: Set<T>) {
|
||||
return prev.size === next.size ? prev : next;
|
||||
}
|
||||
|
||||
const Watermark: React.FC<WatermarkProps> = (props) => {
|
||||
const {
|
||||
/**
|
||||
@ -68,8 +74,8 @@ const Watermark: React.FC<WatermarkProps> = (props) => {
|
||||
const offsetLeft = offset?.[0] ?? gapXCenter;
|
||||
const offsetTop = offset?.[1] ?? gapYCenter;
|
||||
|
||||
const getMarkStyle = () => {
|
||||
const markStyle: React.CSSProperties = {
|
||||
const markStyle = React.useMemo(() => {
|
||||
const mergedStyle: React.CSSProperties = {
|
||||
zIndex,
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
@ -84,197 +90,74 @@ const Watermark: React.FC<WatermarkProps> = (props) => {
|
||||
let positionLeft = offsetLeft - gapXCenter;
|
||||
let positionTop = offsetTop - gapYCenter;
|
||||
if (positionLeft > 0) {
|
||||
markStyle.left = `${positionLeft}px`;
|
||||
markStyle.width = `calc(100% - ${positionLeft}px)`;
|
||||
mergedStyle.left = `${positionLeft}px`;
|
||||
mergedStyle.width = `calc(100% - ${positionLeft}px)`;
|
||||
positionLeft = 0;
|
||||
}
|
||||
if (positionTop > 0) {
|
||||
markStyle.top = `${positionTop}px`;
|
||||
markStyle.height = `calc(100% - ${positionTop}px)`;
|
||||
mergedStyle.top = `${positionTop}px`;
|
||||
mergedStyle.height = `calc(100% - ${positionTop}px)`;
|
||||
positionTop = 0;
|
||||
}
|
||||
markStyle.backgroundPosition = `${positionLeft}px ${positionTop}px`;
|
||||
mergedStyle.backgroundPosition = `${positionLeft}px ${positionTop}px`;
|
||||
|
||||
return markStyle;
|
||||
};
|
||||
return mergedStyle;
|
||||
}, [zIndex, offsetLeft, gapXCenter, offsetTop, gapYCenter]);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const watermarkRef = useRef<HTMLDivElement>();
|
||||
const stopObservation = useRef(false);
|
||||
const [container, setContainer] = React.useState<HTMLDivElement | null>();
|
||||
|
||||
const destroyWatermark = () => {
|
||||
if (watermarkRef.current) {
|
||||
watermarkRef.current.remove();
|
||||
watermarkRef.current = undefined;
|
||||
}
|
||||
};
|
||||
// Used for nest case like Modal, Drawer
|
||||
const [subElements, setSubElements] = React.useState(new Set<HTMLElement>());
|
||||
|
||||
const appendWatermark = (base64Url: string, markWidth: number) => {
|
||||
if (containerRef.current && watermarkRef.current) {
|
||||
stopObservation.current = true;
|
||||
watermarkRef.current.setAttribute(
|
||||
'style',
|
||||
getStyleStr({
|
||||
...getMarkStyle(),
|
||||
backgroundImage: `url('${base64Url}')`,
|
||||
backgroundSize: `${(gapX + markWidth) * BaseSize}px`,
|
||||
}),
|
||||
);
|
||||
containerRef.current?.append(watermarkRef.current);
|
||||
// Delayed execution
|
||||
setTimeout(() => {
|
||||
stopObservation.current = false;
|
||||
// Nest elements should also support watermark
|
||||
const targetElements = React.useMemo(() => {
|
||||
const list = container ? [container] : [];
|
||||
return [...list, ...Array.from(subElements)];
|
||||
}, [container, subElements]);
|
||||
|
||||
// ============================ Content =============================
|
||||
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 syncWatermark = useRafDebounce(renderWatermark);
|
||||
|
||||
// ============================= Effect =============================
|
||||
// Append watermark to the container
|
||||
const [appendWatermark, removeWatermark, isWatermarkEle] = useWatermark(markStyle, gapX);
|
||||
|
||||
useEffect(() => {
|
||||
if (watermarkInfo) {
|
||||
targetElements.forEach((holder) => {
|
||||
appendWatermark(watermarkInfo[0], watermarkInfo[1], holder);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
appendWatermark(canvas.toDataURL(), markWidth);
|
||||
};
|
||||
|
||||
const renderWatermark = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
if (!watermarkRef.current) {
|
||||
watermarkRef.current = document.createElement('div');
|
||||
}
|
||||
|
||||
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);
|
||||
appendWatermark(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,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [watermarkInfo, targetElements]);
|
||||
|
||||
// ============================ Observe =============================
|
||||
const onMutate = (mutations: MutationRecord[]) => {
|
||||
if (stopObservation.current) {
|
||||
return;
|
||||
}
|
||||
mutations.forEach((mutation) => {
|
||||
if (reRendering(mutation, watermarkRef.current)) {
|
||||
destroyWatermark();
|
||||
renderWatermark();
|
||||
if (reRendering(mutation, isWatermarkEle)) {
|
||||
syncWatermark();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(renderWatermark, [
|
||||
useMutateObserver(targetElements, onMutate);
|
||||
|
||||
useEffect(syncWatermark, [
|
||||
rotate,
|
||||
zIndex,
|
||||
width,
|
||||
@ -292,16 +175,39 @@ const Watermark: React.FC<WatermarkProps> = (props) => {
|
||||
offsetTop,
|
||||
]);
|
||||
|
||||
// ============================ Context =============================
|
||||
const watermarkContext = React.useMemo<WatermarkContextProps>(
|
||||
() => ({
|
||||
add: (ele) => {
|
||||
setSubElements((prev) => {
|
||||
const clone = new Set(prev);
|
||||
clone.add(ele);
|
||||
return getSizeDiff(prev, clone);
|
||||
});
|
||||
},
|
||||
remove: (ele) => {
|
||||
removeWatermark(ele);
|
||||
|
||||
setSubElements((prev) => {
|
||||
const clone = new Set(prev);
|
||||
clone.delete(ele);
|
||||
|
||||
return getSizeDiff(prev, clone);
|
||||
});
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// ============================= Render =============================
|
||||
return (
|
||||
<MutateObserver onMutate={onMutate}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={classNames(className, rootClassName)}
|
||||
style={{ position: 'relative', ...style }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</MutateObserver>
|
||||
<div
|
||||
ref={setContainer}
|
||||
className={classNames(className, rootClassName)}
|
||||
style={{ position: 'relative', ...style }}
|
||||
>
|
||||
<WatermarkContext.Provider value={watermarkContext}>{children}</WatermarkContext.Provider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -23,6 +23,7 @@ demo:
|
||||
<code src="./demo/multi-line.tsx">多行水印</code>
|
||||
<code src="./demo/image.tsx">图片水印</code>
|
||||
<code src="./demo/custom.tsx">自定义配置</code>
|
||||
<code src="./demo/portal.tsx">Modal 与 Drawer</code>
|
||||
|
||||
## API
|
||||
|
||||
|
156
components/watermark/useContent.tsx
Normal file
156
components/watermark/useContent.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
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;
|
||||
}
|
26
components/watermark/useRafDebounce.tsx
Normal file
26
components/watermark/useRafDebounce.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import raf from 'rc-util/lib/raf';
|
||||
import { useEvent } from 'rc-util';
|
||||
|
||||
/**
|
||||
* Callback will only execute last one for each raf
|
||||
*/
|
||||
export default function useRafDebounce(callback: VoidFunction) {
|
||||
const executeRef = React.useRef(false);
|
||||
const rafRef = React.useRef<number>();
|
||||
|
||||
const wrapperCallback = useEvent(callback);
|
||||
|
||||
return () => {
|
||||
if (executeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
executeRef.current = true;
|
||||
wrapperCallback();
|
||||
|
||||
rafRef.current = raf(() => {
|
||||
executeRef.current = false;
|
||||
});
|
||||
};
|
||||
}
|
61
components/watermark/useWatermark.tsx
Normal file
61
components/watermark/useWatermark.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import * as React from 'react';
|
||||
import { getStyleStr } from './utils';
|
||||
|
||||
/**
|
||||
* Base size of the canvas, 1 for parallel layout and 2 for alternate layout
|
||||
* Only alternate layout is currently supported
|
||||
*/
|
||||
export const BaseSize = 2;
|
||||
export const FontGap = 3;
|
||||
|
||||
export type AppendWatermark = (
|
||||
base64Url: string,
|
||||
markWidth: number,
|
||||
container: HTMLElement,
|
||||
) => void;
|
||||
|
||||
export default function useWatermark(
|
||||
markStyle: React.CSSProperties,
|
||||
gapX: number,
|
||||
): [
|
||||
appendWatermark: AppendWatermark,
|
||||
removeWatermark: (container: HTMLElement) => void,
|
||||
isWatermarkEle: (ele: Node) => boolean,
|
||||
] {
|
||||
const [watermarkMap] = React.useState(() => new Map<HTMLElement, HTMLDivElement>());
|
||||
|
||||
const appendWatermark = (base64Url: string, markWidth: number, container: HTMLElement) => {
|
||||
if (container) {
|
||||
if (!watermarkMap.get(container)) {
|
||||
const newWatermarkEle = document.createElement('div');
|
||||
watermarkMap.set(container, newWatermarkEle);
|
||||
}
|
||||
|
||||
const watermarkEle = watermarkMap.get(container)!;
|
||||
|
||||
watermarkEle.setAttribute(
|
||||
'style',
|
||||
getStyleStr({
|
||||
...markStyle,
|
||||
backgroundImage: `url('${base64Url}')`,
|
||||
backgroundSize: `${(gapX + markWidth) * BaseSize}px`,
|
||||
}),
|
||||
);
|
||||
container.append(watermarkEle);
|
||||
}
|
||||
};
|
||||
|
||||
const removeWatermark = (container: HTMLElement) => {
|
||||
const watermarkEle = watermarkMap.get(container);
|
||||
|
||||
if (watermarkEle && container) {
|
||||
container.removeChild(watermarkEle);
|
||||
}
|
||||
|
||||
watermarkMap.delete(container);
|
||||
};
|
||||
|
||||
const isWatermarkEle = (ele: any) => Array.from(watermarkMap.values()).includes(ele);
|
||||
|
||||
return [appendWatermark, removeWatermark, isWatermarkEle];
|
||||
}
|
@ -27,14 +27,14 @@ export function rotateWatermark(
|
||||
}
|
||||
|
||||
/** Whether to re-render the watermark */
|
||||
export const reRendering = (mutation: MutationRecord, watermarkElement?: HTMLElement) => {
|
||||
export const reRendering = (mutation: MutationRecord, isWatermarkEle: (ele: any) => boolean) => {
|
||||
let flag = false;
|
||||
// Whether to delete the watermark node
|
||||
if (mutation.removedNodes.length) {
|
||||
flag = Array.from(mutation.removedNodes).some((node) => node === watermarkElement);
|
||||
flag = Array.from(mutation.removedNodes).some((node) => isWatermarkEle(node));
|
||||
}
|
||||
// Whether the watermark dom property value has been modified
|
||||
if (mutation.type === 'attributes' && mutation.target === watermarkElement) {
|
||||
if (mutation.type === 'attributes' && isWatermarkEle(mutation.target)) {
|
||||
flag = true;
|
||||
}
|
||||
return flag;
|
||||
|
10
package.json
10
package.json
@ -116,7 +116,7 @@
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@ctrl/tinycolor": "^3.6.0",
|
||||
"@rc-component/color-picker": "~1.4.0",
|
||||
"@rc-component/mutate-observer": "^1.0.0",
|
||||
"@rc-component/mutate-observer": "^1.1.0",
|
||||
"@rc-component/tour": "~1.8.1",
|
||||
"@rc-component/trigger": "^1.15.0",
|
||||
"classnames": "^2.2.6",
|
||||
@ -126,11 +126,11 @@
|
||||
"rc-cascader": "~3.14.0",
|
||||
"rc-checkbox": "~3.1.0",
|
||||
"rc-collapse": "~3.7.0",
|
||||
"rc-dialog": "~9.1.0",
|
||||
"rc-drawer": "~6.2.0",
|
||||
"rc-dialog": "~9.2.0",
|
||||
"rc-drawer": "~6.4.1",
|
||||
"rc-dropdown": "~4.1.0",
|
||||
"rc-field-form": "~1.36.0",
|
||||
"rc-image": "~7.1.0",
|
||||
"rc-image": "~7.2.0",
|
||||
"rc-input": "~1.1.0",
|
||||
"rc-input-number": "~8.0.2",
|
||||
"rc-mentions": "~2.5.0",
|
||||
@ -154,7 +154,7 @@
|
||||
"rc-tree": "~5.7.6",
|
||||
"rc-tree-select": "~5.11.0",
|
||||
"rc-upload": "~4.3.0",
|
||||
"rc-util": "^5.32.0",
|
||||
"rc-util": "^5.36.0",
|
||||
"scroll-into-view-if-needed": "^3.0.3",
|
||||
"throttle-debounce": "^5.0.0"
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user