diff --git a/components/_util/hooks/_vueuse/useMutationObserver.ts b/components/_util/hooks/_vueuse/useMutationObserver.ts new file mode 100644 index 000000000..3a191d396 --- /dev/null +++ b/components/_util/hooks/_vueuse/useMutationObserver.ts @@ -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; diff --git a/components/components.ts b/components/components.ts index dd4c8c553..83a933dcb 100644 --- a/components/components.ts +++ b/components/components.ts @@ -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'; diff --git a/components/watermark/__tests__/demo.test.js b/components/watermark/__tests__/demo.test.js new file mode 100644 index 000000000..60b75d580 --- /dev/null +++ b/components/watermark/__tests__/demo.test.js @@ -0,0 +1,3 @@ +import demoTest from '../../../tests/shared/demoTest'; + +demoTest('watermark'); diff --git a/components/watermark/__tests__/index.test.js b/components/watermark/__tests__/index.test.js new file mode 100644 index 000000000..badcbd7d3 --- /dev/null +++ b/components/watermark/__tests__/index.test.js @@ -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 ; + }; + }, + }); + expect(wrapper.find('.watermark').exists()).toBe(true); + expect(wrapper.html()).toMatchSnapshot(); + }); +}); diff --git a/components/watermark/demo/basic.vue b/components/watermark/demo/basic.vue new file mode 100644 index 000000000..b48df546b --- /dev/null +++ b/components/watermark/demo/basic.vue @@ -0,0 +1,23 @@ + +--- +order: 0 +title: + zh-CN: 基本 + en-US: Basic +--- + +## zh-CN + +最简单的用法。 + +## en-US + +The most basic usage. + + + + diff --git a/components/watermark/demo/custom.vue b/components/watermark/demo/custom.vue new file mode 100644 index 000000000..7cdeb0f7e --- /dev/null +++ b/components/watermark/demo/custom.vue @@ -0,0 +1,118 @@ + +--- +order: 0 +title: + zh-CN: 自定义配置 + en-US: Custom +--- + +## zh-CN + +通过自定义参数配置预览水印效果。 + +## en-US + +Preview the watermark effect by configuring custom parameters. + + + + + + diff --git a/components/watermark/demo/image.vue b/components/watermark/demo/image.vue new file mode 100644 index 000000000..59dd33e7f --- /dev/null +++ b/components/watermark/demo/image.vue @@ -0,0 +1,27 @@ + +--- +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. + + + + diff --git a/components/watermark/demo/index.vue b/components/watermark/demo/index.vue new file mode 100644 index 000000000..38012df8e --- /dev/null +++ b/components/watermark/demo/index.vue @@ -0,0 +1,28 @@ + + + diff --git a/components/watermark/demo/multi-line.vue b/components/watermark/demo/multi-line.vue new file mode 100644 index 000000000..af8226524 --- /dev/null +++ b/components/watermark/demo/multi-line.vue @@ -0,0 +1,23 @@ + +--- +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. + + + + diff --git a/components/watermark/index.en-US.md b/components/watermark/index.en-US.md new file mode 100644 index 000000000..46560d8aa --- /dev/null +++ b/components/watermark/index.en-US.md @@ -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 + + +| 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 | | diff --git a/components/watermark/index.tsx b/components/watermark/index.tsx new file mode 100644 index 000000000..5b07db590 --- /dev/null +++ b/components/watermark/index.tsx @@ -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, + 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>>; +const Watermark = defineComponent({ + name: 'AWatermark', + inheritAttrs: false, + props: watermarkProps(), + setup(props, { slots, attrs }) { + const containerRef = shallowRef(); + const watermarkRef = shallowRef(); + 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 ( +
+ {slots.default?.()} +
+ ); + }; + }, +}); + +export default withInstall(Watermark); diff --git a/components/watermark/index.zh-CN.md b/components/watermark/index.zh-CN.md new file mode 100644 index 000000000..1413d709a --- /dev/null +++ b/components/watermark/index.zh-CN.md @@ -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 + + +| 参数 | 说明 | 类型 | 默认值 | 版本 | +| --- | --- | --- | --- | --- | +| 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 | | diff --git a/components/watermark/utils.ts b/components/watermark/utils.ts new file mode 100644 index 000000000..60d23f88e --- /dev/null +++ b/components/watermark/utils.ts @@ -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; +}; diff --git a/site/src/demo.js b/site/src/demo.js index 9f74d68c3..b7a3f07bd 100644 --- a/site/src/demo.js +++ b/site/src/demo.js @@ -224,6 +224,13 @@ export default { type: 'Other', title: 'BackTop', }, + watermark: { + category: 'Components', + subtitle: '水印', + type: 'Other', + title: 'Watermark', + cols: 1, + }, modal: { category: 'Components', subtitle: '对话框',