feat(components): watermark component (#14236)

* feat(components): watermark component

be able to set text,multi-text,image as watermark

* docs(components): update image watermark example

update the image for dark mode
This commit is contained in:
刘臻 2023-10-10 17:42:22 +08:00 committed by GitHub
parent 0a46c24da9
commit 7916200ba4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 713 additions and 0 deletions

View File

@ -323,6 +323,10 @@
{
"link": "/divider",
"text": "Divider"
},
{
"link": "/watermark",
"text": "Watermark"
}
]
}

View File

@ -0,0 +1,75 @@
---
title: Watermark
lang: en-US
---
# Watermark
Add specific text or patterns to the page.
## Basic usage
The most basic usage
:::demo
watermark/basic
:::
## Multi-line watermark
Use "content" to set an array of strings to specify multi-line text watermark content.
:::demo
watermark/multi-line
:::
## Image watermark
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.
:::demo
watermark/image
:::
## Custom configuration
Preview the watermark effect by configuring custom parameters.
:::demo
watermark/custom
:::
## Watermark API
### Watermark Attributes
| Name | Description | Type | Default |
| ------- | ----------------------------------------------------------------------------------------------- | ------------------ | -------------------------- |
| 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
| Name | Description | Type | Default |
| ---------- | ----------- | ---------------------------------------------------- | --------------- |
| color | font color | string | rgba(0,0,0,.15) |
| fontSize | font size | number | 16 |
| fontWeight | font weight | ^[enum]`'normal \| 'light' \| 'weight' \| number` | normal |
| fontFamily | font family | string | sans-serif |
| fontStyle | font style | ^[enum]`'none' \| 'normal' \| 'italic' \| 'oblique'` | normal |

View File

@ -0,0 +1,5 @@
<template>
<el-watermark>
<div style="height: 500px" />
</el-watermark>
</template>

View File

@ -0,0 +1,103 @@
<script setup lang="ts">
import { reactive } from 'vue'
const config = reactive({
content: 'Element Plus',
font: {
fontSize: 16,
color: 'rgba(0, 0, 0, 0.15)',
},
zIndex: -1,
rotate: -22,
gap: [100, 100] as [number, number],
offset: [] as unknown as [number, number],
})
</script>
<template>
<div class="wrapper">
<el-watermark
class="watermark"
:content="config.content"
:font="config.font"
:z-index="config.zIndex"
:rotate="config.rotate"
:gap="config.gap"
:offset="config.offset"
>
<div class="demo">
<h1>Element Plus</h1>
<h2>a Vue 3 based component library for designers and developers</h2>
<img src="/images/hamburger.png" alt="示例图片" />
</div>
</el-watermark>
<el-form
class="form"
:model="config"
label-position="top"
label-width="50px"
>
<el-form-item label="Content">
<el-input v-model="config.content" />
</el-form-item>
<el-form-item label="Color">
<el-color-picker v-model="config.font.color" show-alpha />
</el-form-item>
<el-form-item label="FontSize">
<el-slider v-model="config.font.fontSize" />
</el-form-item>
<el-form-item label="zIndex">
<el-slider v-model="config.zIndex" />
</el-form-item>
<el-form-item label="Rotate">
<el-slider v-model="config.rotate" :min="-180" :max="180" />
</el-form-item>
<el-form-item label="Gap">
<el-space>
<el-input-number v-model="config.gap[0]" controls-position="right" />
<el-input-number v-model="config.gap[1]" controls-position="right" />
</el-space>
</el-form-item>
<el-form-item label="Offset">
<el-space>
<el-input-number
v-model="config.offset[0]"
placeholder="offsetLeft"
controls-position="right"
/>
<el-input-number
v-model="config.offset[1]"
placeholder="offsetTop"
controls-position="right"
/>
</el-space>
</el-form-item>
</el-form>
</div>
</template>
<style scoped>
.wrapper {
display: flex;
}
.watermark {
display: flex;
flex: auto;
}
.demo {
flex: auto;
}
.form {
width: 330px;
margin-left: 20px;
border-left: 1px solid #eee;
padding-left: 20px;
}
img {
z-index: 10;
width: 100%;
max-width: 300px;
position: relative;
}
</style>

View File

@ -0,0 +1,9 @@
<template>
<el-watermark
:width="130"
:height="30"
image="https://element-plus.org/images/element-plus-logo.svg"
>
<div style="height: 500px" />
</el-watermark>
</template>

View File

@ -0,0 +1,5 @@
<template>
<el-watermark :content="['Element+', 'Element Plus']">
<div style="height: 500px" />
</el-watermark>
</template>

View File

@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Watermark.vue > create 1`] = `"<div style=\\"position: relative;\\" class=\\"watermark\\">Rem is the best girl</div>"`;

View File

@ -0,0 +1,22 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import Watermark from '../src/watermark.vue'
const AXIOM = 'Rem is the best girl'
describe('Watermark.vue', () => {
it('create', () => {
const wrapper = mount(() => (
<Watermark class="watermark">{AXIOM}</Watermark>
))
expect(wrapper.classes()).toContain('watermark')
expect(wrapper.html()).toMatchSnapshot()
})
it('slots', () => {
const wrapper = mount(() => <Watermark>{AXIOM}</Watermark>)
expect(wrapper.text()).toContain(AXIOM)
})
})

View File

@ -0,0 +1,7 @@
import { withInstall } from '@element-plus/utils'
import Watermark from './src/watermark.vue'
export const ElWatermark = withInstall(Watermark)
export default ElWatermark
export * from './src/watermark'

View File

@ -0,0 +1,145 @@
import type { WatermarkProps } from './watermark'
export const FontGap = 3
function prepareCanvas(
width: number,
height: number,
ratio = 1
): [
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
realWidth: number,
realHeight: number
] {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
const realWidth = width * ratio
const realHeight = height * ratio
canvas.setAttribute('width', `${realWidth}px`)
canvas.setAttribute('height', `${realHeight}px`)
ctx.save()
return [ctx, canvas, realWidth, realHeight]
}
/**
* Get the clips of text content.
* This is a lazy hook function since SSR no need this
*/
export default function useClips() {
// Get single clips
function getClips(
content: NonNullable<WatermarkProps['content']> | HTMLImageElement,
rotate: number,
ratio: number,
width: number,
height: number,
font: Required<NonNullable<WatermarkProps['font']>>,
gapX: number,
gapY: number
): [dataURL: string, finalWidth: number, finalHeight: number] {
// ================= Text / Image =================
const [ctx, canvas, contentWidth, contentHeight] = prepareCanvas(
width,
height,
ratio
)
if (content instanceof HTMLImageElement) {
// Image
ctx.drawImage(content, 0, 0, contentWidth, contentHeight)
} else {
// Text
const { color, fontSize, fontStyle, fontWeight, fontFamily } = font
const mergedFontSize = Number(fontSize) * ratio
ctx.font = `${fontStyle} normal ${fontWeight} ${mergedFontSize}px/${height}px ${fontFamily}`
ctx.fillStyle = color
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
const contents = Array.isArray(content) ? content : [content]
contents?.forEach((item, index) => {
ctx.fillText(
item ?? '',
contentWidth / 2,
index * (mergedFontSize + FontGap * ratio)
)
})
}
// ==================== Rotate ====================
const angle = (Math.PI / 180) * Number(rotate)
const maxSize = Math.max(width, height)
const [rCtx, rCanvas, realMaxSize] = prepareCanvas(maxSize, maxSize, ratio)
// Copy from `ctx` and rotate
rCtx.translate(realMaxSize / 2, realMaxSize / 2)
rCtx.rotate(angle)
if (contentWidth > 0 && contentHeight > 0) {
rCtx.drawImage(canvas, -contentWidth / 2, -contentHeight / 2)
}
// Get boundary of rotated text
function getRotatePos(x: number, y: number) {
const targetX = x * Math.cos(angle) - y * Math.sin(angle)
const targetY = x * Math.sin(angle) + y * Math.cos(angle)
return [targetX, targetY]
}
let left = 0
let right = 0
let top = 0
let bottom = 0
const halfWidth = contentWidth / 2
const halfHeight = contentHeight / 2
const points = [
[0 - halfWidth, 0 - halfHeight],
[0 + halfWidth, 0 - halfHeight],
[0 + halfWidth, 0 + halfHeight],
[0 - halfWidth, 0 + halfHeight],
]
points.forEach(([x, y]) => {
const [targetX, targetY] = getRotatePos(x, y)
left = Math.min(left, targetX)
right = Math.max(right, targetX)
top = Math.min(top, targetY)
bottom = Math.max(bottom, targetY)
})
const cutLeft = left + realMaxSize / 2
const cutTop = top + realMaxSize / 2
const cutWidth = right - left
const cutHeight = bottom - top
// ================ Fill Alternate ================
const realGapX = gapX * ratio
const realGapY = gapY * ratio
const filledWidth = (cutWidth + realGapX) * 2
const filledHeight = cutHeight + realGapY
const [fCtx, fCanvas] = prepareCanvas(filledWidth, filledHeight)
function drawImg(targetX = 0, targetY = 0) {
fCtx.drawImage(
rCanvas,
cutLeft,
cutTop,
cutWidth,
cutHeight,
targetX,
targetY,
cutWidth,
cutHeight
)
}
drawImg()
drawImg(cutWidth + realGapX, -cutHeight / 2 - realGapY / 2)
drawImg(cutWidth + realGapX, +cutHeight / 2 + realGapY / 2)
return [fCanvas.toDataURL(), filledWidth / ratio, filledHeight / ratio]
}
return getClips
}

View File

@ -0,0 +1,37 @@
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) =>
`${toLowercaseSeparator(key)}: ${style[key as keyof CSSProperties]};`
)
.join(' ')
}
/** Returns the ratio of the device's physical pixel resolution to the css pixel resolution */
export function getPixelRatio() {
return window.devicePixelRatio || 1
}
/** 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 && watermarkElement) {
flag = Array.from(mutation.removedNodes).includes(watermarkElement)
}
// Whether the watermark dom property value has been modified
if (mutation.type === 'attributes' && mutation.target === watermarkElement) {
flag = true
}
return flag
}

View File

@ -0,0 +1,70 @@
import { buildProps, definePropType } from '@element-plus/utils'
import type { ExtractPropTypes } from 'vue'
import type Watermark from './watermark.vue'
export interface WatermarkFontType {
color?: string
fontSize?: number | string
fontWeight?: 'normal' | 'light' | 'weight' | number
fontStyle?: 'none' | 'normal' | 'italic' | 'oblique'
fontFamily?: string
}
export const watermarkProps = buildProps({
/**
* @description The z-index of the appended watermark element
*/
zIndex: {
type: Number,
default: 9,
},
/**
* @description The rotation angle of the watermark
*/
rotate: {
type: Number,
default: -22,
},
/**
* @description The width of the watermark
*/
width: Number,
/**
* @description The height of the watermark
*/
height: Number,
/**
* @description Image source, it is recommended to export 2x or 3x image, high priority (support base64 format)
*/
image: String,
/**
* @description Watermark text content
*/
content: {
type: definePropType<string | string[]>([String, Array]),
default: 'Element Plus',
},
/**
* @description Text style
*/
font: {
type: definePropType<WatermarkFontType>(Object),
},
/**
* @description The spacing between watermarks
*/
gap: {
type: definePropType<[number, number]>(Array),
default: () => [100, 100],
},
/**
* @description The offset of the watermark from the upper left corner of the container. The default is gap/2
*/
offset: {
type: definePropType<[number, number]>(Array),
},
} as const)
export type WatermarkProps = ExtractPropTypes<typeof watermarkProps>
export type WatermarkInstance = InstanceType<typeof Watermark>

View File

@ -0,0 +1,225 @@
<template>
<div ref="containerRef" :style="[style]">
<slot />
</div>
</template>
<script lang="ts" setup>
import {
computed,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
watch,
} from 'vue'
import { useMutationObserver } from '@vueuse/core'
import { watermarkProps } from './watermark'
import { getPixelRatio, getStyleStr, reRendering } from './utils'
import useClips, { FontGap } from './useClips'
import type { WatermarkProps } from './watermark'
import type { CSSProperties } from 'vue'
defineOptions({
name: 'ElWatermark',
})
const style: CSSProperties = {
position: 'relative',
}
const props = defineProps(watermarkProps)
const color = computed(() => props.font?.color ?? 'rgba(0,0,0,.15)')
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 gapX = computed(() => props.gap[0])
const gapY = computed(() => props.gap[1])
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 getMarkStyle = () => {
const markStyle: CSSProperties = {
zIndex: props.zIndex,
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 containerRef = shallowRef<HTMLDivElement | null>(null)
const watermarkRef = shallowRef<HTMLDivElement>()
const stopObservation = ref(false)
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: `${Math.floor(markWidth)}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 image = props.image
const content = props.content
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 sizes = contents.map((item) => {
const metrics = ctx.measureText(item!)
return [
metrics.width,
metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent,
]
})
defaultWidth = Math.ceil(Math.max(...sizes.map((size) => size[0])))
defaultHeight =
Math.ceil(Math.max(...sizes.map((size) => size[1]))) * contents.length +
(contents.length - 1) * FontGap
}
return [width ?? defaultWidth, height ?? defaultHeight] as const
}
const getClips = useClips()
const renderWatermark = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const image = props.image
const content = props.content
const rotate = props.rotate
if (ctx) {
if (!watermarkRef.value) {
watermarkRef.value = document.createElement('div')
}
const ratio = getPixelRatio()
const [markWidth, markHeight] = getMarkSize(ctx)
const drawCanvas = (
drawContent?: NonNullable<WatermarkProps['content']> | HTMLImageElement
) => {
const [textClips, clipWidth] = getClips(
drawContent || '',
rotate,
ratio,
markWidth,
markHeight,
{
color: color.value,
fontSize: fontSize.value,
fontStyle: fontStyle.value,
fontWeight: fontWeight.value,
fontFamily: fontFamily.value,
},
gapX.value,
gapY.value
)
appendWatermark(textClips, clipWidth)
}
if (image) {
const img = new Image()
img.onload = () => {
drawCanvas(img)
}
img.onerror = () => {
drawCanvas(content)
}
img.crossOrigin = 'anonymous'
img.referrerPolicy = 'no-referrer'
img.src = image
} else {
drawCanvas(content)
}
}
}
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,
})
</script>

View File

@ -102,6 +102,7 @@ import { ElTree } from '@element-plus/components/tree'
import { ElTreeSelect } from '@element-plus/components/tree-select'
import { ElTreeV2 } from '@element-plus/components/tree-v2'
import { ElUpload } from '@element-plus/components/upload'
import { ElWatermark } from '@element-plus/components/watermark'
import type { Plugin } from 'vue'
@ -204,4 +205,5 @@ export default [
ElTreeSelect,
ElTreeV2,
ElUpload,
ElWatermark,
] as Plugin[]

View File

@ -97,6 +97,7 @@ declare module '@vue/runtime-core' {
ElDescriptionsItem: typeof import('../packages/element-plus')['ElDescriptionsItem']
ElResult: typeof import('../packages/element-plus')['ElResult']
ElSelectV2: typeof import('../packages/element-plus')['ElSelectV2']
ElWatermark: typeof import('../packages/element-plus')['ElWatermark']
}
interface ComponentCustomProperties {