feat: add watermark (#6300)
* feat: add watermark * feat: add watermark demo * feat: add mutationObserver * feat: add watermark demopull/6320/head
parent
dd063b8275
commit
6058ca5576
|
@ -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>;
|
|
@ -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';
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import demoTest from '../../../tests/shared/demoTest';
|
||||
|
||||
demoTest('watermark');
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
|
@ -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'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' 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 | |
|
|
@ -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);
|
|
@ -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 | |
|
|
@ -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;
|
||||
};
|
|
@ -224,6 +224,13 @@ export default {
|
|||
type: 'Other',
|
||||
title: 'BackTop',
|
||||
},
|
||||
watermark: {
|
||||
category: 'Components',
|
||||
subtitle: '水印',
|
||||
type: 'Other',
|
||||
title: 'Watermark',
|
||||
cols: 1,
|
||||
},
|
||||
modal: {
|
||||
category: 'Components',
|
||||
subtitle: '对话框',
|
||||
|
|
Loading…
Reference in New Issue