feat: add watermark (#6300)

* feat: add watermark

* feat: add watermark demo

* feat: add mutationObserver

* feat: add watermark demo
pull/6320/head
Zev Zhu 2023-02-28 10:35:10 +08:00 committed by GitHub
parent dd063b8275
commit 6058ca5576
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 710 additions and 1 deletions

View File

@ -0,0 +1,62 @@
import { tryOnScopeDispose } from './tryOnScopeDispose';
import { watch } from 'vue';
import type { MaybeElementRef } from './unrefElement';
import { unrefElement } from './unrefElement';
import { useSupported } from './useSupported';
import type { ConfigurableWindow } from './_configurable';
import { defaultWindow } from './_configurable';
export interface UseMutationObserverOptions extends MutationObserverInit, ConfigurableWindow {}
/**
* Watch for changes being made to the DOM tree.
*
* @see https://vueuse.org/useMutationObserver
* @see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver MutationObserver MDN
* @param target
* @param callback
* @param options
*/
export function useMutationObserver(
target: MaybeElementRef,
callback: MutationCallback,
options: UseMutationObserverOptions = {},
) {
const { window = defaultWindow, ...mutationOptions } = options;
let observer: MutationObserver | undefined;
const isSupported = useSupported(() => window && 'MutationObserver' in window);
const cleanup = () => {
if (observer) {
observer.disconnect();
observer = undefined;
}
};
const stopWatch = watch(
() => unrefElement(target),
el => {
cleanup();
if (isSupported.value && window && el) {
observer = new MutationObserver(callback);
observer!.observe(el, mutationOptions);
}
},
{ immediate: true },
);
const stop = () => {
cleanup();
stopWatch();
};
tryOnScopeDispose(stop);
return {
isSupported,
stop,
};
}
export type UseMutationObserverReturn = ReturnType<typeof useMutationObserver>;

View File

@ -245,6 +245,8 @@ export { default as Upload, UploadDragger } from './upload';
export { default as LocaleProvider } from './locale-provider';
export type { SegmentedProps } from './segmented';
export { default as Watermark } from './watermark';
export type { WatermarkProps } from './watermark';
export type { SegmentedProps } from './segmented';
export { default as Segmented } from './segmented';

View File

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

View File

@ -0,0 +1,29 @@
import Watermark from '..';
import mountTest from '../../../tests/shared/mountTest';
import { mount } from '@vue/test-utils';
describe('Watermark', () => {
mountTest(Watermark);
const mockSrcSet = jest.spyOn(Image.prototype, 'src', 'set');
beforeAll(() => {
mockSrcSet.mockImplementation(function fn() {
this.onload?.();
});
});
afterAll(() => {
mockSrcSet.mockRestore();
});
it('The watermark should render successfully ', function () {
const wrapper = mount({
setup() {
return () => {
return <Watermark class="watermark" content="Ant Design" />;
};
},
});
expect(wrapper.find('.watermark').exists()).toBe(true);
expect(wrapper.html()).toMatchSnapshot();
});
});

View File

@ -0,0 +1,23 @@
<docs>
---
order: 0
title:
zh-CN: 基本
en-US: Basic
---
## zh-CN
最简单的用法
## en-US
The most basic usage.
</docs>
<template>
<a-watermark content="Ant Design Vue">
<div style="height: 500px" />
</a-watermark>
</template>

View File

@ -0,0 +1,118 @@
<docs>
---
order: 0
title:
zh-CN: 自定义配置
en-US: Custom
---
## zh-CN
通过自定义参数配置预览水印效果
## en-US
Preview the watermark effect by configuring custom parameters.
</docs>
<template>
<div style="display: flex">
<a-watermark v-bind="model">
<a-typography>
<a-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.
</a-typography-paragraph>
<a-typography-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.
</a-typography-paragraph>
<a-typography-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.
</a-typography-paragraph>
</a-typography>
<img
style="z-index: 10; width: 100%; max-width: 800px; position: relative"
src="https://gw.alipayobjects.com/mdn/rms_08e378/afts/img/A*zx7LTI_ECSAAAAAAAAAAAABkARQnAQ"
alt="示例图片"
/>
</a-watermark>
<a-form
style="
width: 280px;
flex-shrink: 0;
border-left: 1px solid #eee;
padding-left: 20px;
margin-left: 20px;
"
layout="vertical"
:model="model"
>
<a-form-item name="content" label="Content">
<a-input v-model:value="model.content" />
</a-form-item>
<a-form-item name="font.fontSize" label="FontSize">
<a-slider v-model:value="model.font.fontSize" :step="1" :min="0" :max="100" />
</a-form-item>
<a-form-item name="zIndex" label="zIndex">
<a-slider v-model:value="model.zIndex" :step="1" :min="0" :max="100" />
</a-form-item>
<a-form-item name="rotate" label="Rotate">
<a-slider v-model:value="model.rotate" :step="1" :min="-180" :max="180" />
</a-form-item>
<a-form-item label="Gap" style="margin-bottom: 0">
<a-space style="display: flex" align="baseline">
<a-form-item :name="['gap', 0]">
<a-input-number v-model:value="model.gap[0]" placeholder="gapX" />
</a-form-item>
<a-form-item :name="['gap', 1]">
<a-input-number v-model:value="model.gap[1]" placeholder="gapY" />
</a-form-item>
</a-space>
</a-form-item>
<a-form-item label="Offset" style="margin-bottom: 0">
<a-space style="display: flex" align="baseline">
<a-form-item :name="['offset', 0]">
<a-input-number v-model:value="model.offset[0]" placeholder="offsetLeft" />
</a-form-item>
<a-form-item :name="['offset', 1]">
<a-input-number v-model:value="model.offset[1]" placeholder="offsetTop" />
</a-form-item>
</a-space>
</a-form-item>
</a-form>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive } from 'vue';
export default defineComponent({
setup() {
const model = reactive({
content: 'Ant Design Vue',
font: {
fontSize: 16,
},
zIndex: 11,
rotate: -22,
gap: [100, 100] as [number, number],
offset: [],
});
return {
model,
};
},
});
</script>

View File

@ -0,0 +1,27 @@
<docs>
---
order: 0
title:
zh-CN: 图片水印
en-US: Image watermark
---
## 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.
</docs>
<template>
<a-watermark
:height="30"
:width="130"
image="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*lkAoRbywo0oAAAAAAAAAAAAADrJ8AQ/original"
>
<div style="height: 500px" />
</a-watermark>
</template>

View File

@ -0,0 +1,28 @@
<template>
<demo-sort :cols="1">
<basic />
<multi-line />
<watermark-image />
<custom />
</demo-sort>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import CN from '../index.zh-CN.md';
import US from '../index.en-US.md';
import Basic from './basic.vue';
import MultiLine from './multi-line.vue';
import WatermarkImage from './image.vue';
import Custom from './custom.vue';
export default defineComponent({
CN,
US,
components: {
Basic,
MultiLine,
WatermarkImage,
Custom,
},
});
</script>

View File

@ -0,0 +1,23 @@
<docs>
---
order: 0
title:
zh-CN: 多行水印
en-US: Multi-line watermark
---
## zh-CN
通过 `content` 设置 字符串数组 指定多行文字水印内容
## en-US
Use 'content' to set a string array to specify multi-line text watermark content.
</docs>
<template>
<a-watermark :content="['Ant Design Vue', 'Happy Working']">
<div style="height: 500px" />
</a-watermark>
</template>

View File

@ -0,0 +1,41 @@
---
category: Components
type: Other
title: Watermark
cover: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*wr1ISY50SyYAAAAAAAAAAAAADrJ8AQ/original
coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*duAQQbjHlHQAAAAAAAAAAAAADrJ8AQ/original
---
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.
## 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\] | \[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
<!-- prettier-ignore -->
| Property | Description | Type | Default | Version |
| --- | --- | --- | --- | --- |
| 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 type { PropType, ExtractPropTypes, CSSProperties } from 'vue';
import { computed, defineComponent, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue';
import { getStyleStr, getPixelRatio, rotateWatermark, reRendering } from './utils';
import { withInstall } from '../_util/type';
import { useMutationObserver } from '../_util/hooks/_vueuse/useMutationObserver';
/**
* 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;
export interface WatermarkFontType {
color?: string;
fontSize?: number | string;
fontWeight?: 'normal' | 'light' | 'weight' | number;
fontStyle?: 'none' | 'normal' | 'italic' | 'oblique';
fontFamily?: string;
}
export const watermarkProps = () => ({
zIndex: Number,
rotate: Number,
width: Number,
height: Number,
image: String,
content: [String, Array],
font: {
type: Object as PropType<WatermarkFontType>,
default: () => ({
fontSize: 16,
color: 'rgba(0, 0, 0, 0.15)',
fontWeight: 'normal',
fontStyle: 'normal',
fontFamily: 'sans-serif',
}),
},
rootClassName: String,
gap: {
type: [Array, Object] as PropType<[number, number]>,
default: undefined,
},
offset: {
type: [Array, Object] as PropType<[number, number]>,
default: undefined,
},
});
export type WatermarkProps = Partial<ExtractPropTypes<ReturnType<typeof watermarkProps>>>;
const Watermark = defineComponent({
name: 'AWatermark',
inheritAttrs: false,
props: watermarkProps(),
setup(props, { slots, attrs }) {
const containerRef = shallowRef<HTMLDivElement>();
const watermarkRef = shallowRef<HTMLDivElement>();
const stopObservation = shallowRef(false);
const gapX = computed(() => props.gap?.[0] ?? 100);
const gapY = computed(() => props.gap?.[1] ?? 100);
const gapXCenter = computed(() => gapX.value / 2);
const gapYCenter = computed(() => gapY.value / 2);
const offsetLeft = computed(() => props.offset?.[0] ?? gapXCenter.value);
const offsetTop = computed(() => props.offset?.[1] ?? gapYCenter.value);
const fontSize = computed(() => props.font?.fontSize ?? 16);
const fontWeight = computed(() => props.font?.fontWeight ?? 'normal');
const fontStyle = computed(() => props.font?.fontStyle ?? 'normal');
const fontFamily = computed(() => props.font?.fontFamily ?? 'sans-serif');
const color = computed(() => props.font?.color ?? 'rgba(0, 0, 0, 0.15)');
const getMarkStyle = () => {
const markStyle: CSSProperties = {
zIndex: props.zIndex ?? 9,
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
backgroundRepeat: 'repeat',
};
/** Calculate the style of the offset */
let positionLeft = offsetLeft.value - gapXCenter.value;
let positionTop = offsetTop.value - gapYCenter.value;
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 destroyWatermark = () => {
if (watermarkRef.value) {
watermarkRef.value.remove();
watermarkRef.value = undefined;
}
};
const appendWatermark = (base64Url: string, markWidth: number) => {
if (containerRef.value && watermarkRef.value) {
stopObservation.value = true;
watermarkRef.value.setAttribute(
'style',
getStyleStr({
...getMarkStyle(),
backgroundImage: `url('${base64Url}')`,
backgroundSize: `${(gapX.value + markWidth) * BaseSize}px`,
}),
);
containerRef.value?.append(watermarkRef.value);
// Delayed execution
setTimeout(() => {
stopObservation.value = false;
});
}
};
/**
* 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;
const content = props.content;
const image = props.image;
const width = props.width;
const height = props.height;
if (!image && ctx.measureText) {
ctx.font = `${Number(fontSize.value)}px ${fontFamily.value}`;
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.value) * 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 content = props.content;
const mergedFontSize = Number(fontSize.value) * ratio;
ctx.font = `${fontStyle.value} normal ${fontWeight.value} ${mergedFontSize}px/${drawHeight}px ${fontFamily.value}`;
ctx.fillStyle = color.value;
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');
const image = props.image;
const rotate = props.rotate ?? -22;
if (ctx) {
if (!watermarkRef.value) {
watermarkRef.value = document.createElement('div');
}
const ratio = getPixelRatio();
const [markWidth, markHeight] = getMarkSize(ctx);
const canvasWidth = (gapX.value + markWidth) * ratio;
const canvasHeight = (gapY.value + markHeight) * ratio;
canvas.setAttribute('width', `${canvasWidth * BaseSize}px`);
canvas.setAttribute('height', `${canvasHeight * BaseSize}px`);
const drawX = (gapX.value * ratio) / 2;
const drawY = (gapY.value * ratio) / 2;
const drawWidth = markWidth * ratio;
const drawHeight = markHeight * ratio;
const rotateX = (drawWidth + gapX.value * ratio) / 2;
const rotateY = (drawHeight + gapY.value * 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.crossOrigin = 'anonymous';
img.referrerPolicy = 'no-referrer';
img.src = image;
} else {
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);
}
}
};
onMounted(() => {
renderWatermark();
});
watch(
() => props,
() => {
renderWatermark();
},
{
deep: true,
flush: 'post',
},
);
onBeforeUnmount(() => {
destroyWatermark();
});
const onMutate = (mutations: MutationRecord[]) => {
if (stopObservation.value) {
return;
}
mutations.forEach(mutation => {
if (reRendering(mutation, watermarkRef.value)) {
destroyWatermark();
renderWatermark();
}
});
};
useMutationObserver(containerRef, onMutate, {
attributes: true,
});
return () => {
return (
<div
ref={containerRef}
class={[attrs.class, props.rootClassName]}
style={{ position: 'relative' }}
>
{slots.default?.()}
</div>
);
};
},
});
export default withInstall(Watermark);

View File

@ -0,0 +1,42 @@
---
category: Components
subtitle: 水印
type: 其他
title: Watermark
cover: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*wr1ISY50SyYAAAAAAAAAAAAADrJ8AQ/original
coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*duAQQbjHlHQAAAAAAAAAAAAADrJ8AQ/original
---
给页面的某个区域加上水印。
## 何时使用
- 页面需要添加水印标识版权时使用。
- 适用于防止信息盗用。
## 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\] | \[100, 100\] | |
| 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,42 @@
import type { CSSProperties } from 'vue';
/** 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: CSSProperties): string {
return Object.keys(style)
.map((key: keyof 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);
}
/** Whether to re-render the watermark */
export 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;
};

View File

@ -224,6 +224,13 @@ export default {
type: 'Other',
title: 'BackTop',
},
watermark: {
category: 'Components',
subtitle: '水印',
type: 'Other',
title: 'Watermark',
cols: 1,
},
modal: {
category: 'Components',
subtitle: '对话框',