diff --git a/docs/.vitepress/crowdin/en-US/pages/component.json b/docs/.vitepress/crowdin/en-US/pages/component.json
index 77ca9a008e..23d8c09d6a 100644
--- a/docs/.vitepress/crowdin/en-US/pages/component.json
+++ b/docs/.vitepress/crowdin/en-US/pages/component.json
@@ -323,6 +323,10 @@
{
"link": "/divider",
"text": "Divider"
+ },
+ {
+ "link": "/watermark",
+ "text": "Watermark"
}
]
}
diff --git a/docs/en-US/component/watermark.md b/docs/en-US/component/watermark.md
new file mode 100644
index 0000000000..e8d7c4a20c
--- /dev/null
+++ b/docs/en-US/component/watermark.md
@@ -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 |
diff --git a/docs/examples/watermark/basic.vue b/docs/examples/watermark/basic.vue
new file mode 100644
index 0000000000..ead7d1040f
--- /dev/null
+++ b/docs/examples/watermark/basic.vue
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/docs/examples/watermark/custom.vue b/docs/examples/watermark/custom.vue
new file mode 100644
index 0000000000..fca26e3043
--- /dev/null
+++ b/docs/examples/watermark/custom.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
Element Plus
+
a Vue 3 based component library for designers and developers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/examples/watermark/image.vue b/docs/examples/watermark/image.vue
new file mode 100644
index 0000000000..a2953ae865
--- /dev/null
+++ b/docs/examples/watermark/image.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/docs/examples/watermark/multi-line.vue b/docs/examples/watermark/multi-line.vue
new file mode 100644
index 0000000000..8979d66fe2
--- /dev/null
+++ b/docs/examples/watermark/multi-line.vue
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/packages/components/watermark/__tests__/__snapshots__/watermark.test.tsx.snap b/packages/components/watermark/__tests__/__snapshots__/watermark.test.tsx.snap
new file mode 100644
index 0000000000..493e5271ed
--- /dev/null
+++ b/packages/components/watermark/__tests__/__snapshots__/watermark.test.tsx.snap
@@ -0,0 +1,3 @@
+// Vitest Snapshot v1
+
+exports[`Watermark.vue > create 1`] = `"
Rem is the best girl
"`;
diff --git a/packages/components/watermark/__tests__/watermark.test.tsx b/packages/components/watermark/__tests__/watermark.test.tsx
new file mode 100644
index 0000000000..45f373b4ea
--- /dev/null
+++ b/packages/components/watermark/__tests__/watermark.test.tsx
@@ -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(() => (
+ {AXIOM}
+ ))
+
+ expect(wrapper.classes()).toContain('watermark')
+ expect(wrapper.html()).toMatchSnapshot()
+ })
+
+ it('slots', () => {
+ const wrapper = mount(() => {AXIOM})
+
+ expect(wrapper.text()).toContain(AXIOM)
+ })
+})
diff --git a/packages/components/watermark/index.ts b/packages/components/watermark/index.ts
new file mode 100644
index 0000000000..6b05e65ab0
--- /dev/null
+++ b/packages/components/watermark/index.ts
@@ -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'
diff --git a/packages/components/watermark/src/useClips.ts b/packages/components/watermark/src/useClips.ts
new file mode 100644
index 0000000000..f671c34c4d
--- /dev/null
+++ b/packages/components/watermark/src/useClips.ts
@@ -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 | HTMLImageElement,
+ rotate: number,
+ ratio: number,
+ width: number,
+ height: number,
+ font: Required>,
+ 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
+}
diff --git a/packages/components/watermark/src/utils.ts b/packages/components/watermark/src/utils.ts
new file mode 100644
index 0000000000..2507f132b8
--- /dev/null
+++ b/packages/components/watermark/src/utils.ts
@@ -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
+}
diff --git a/packages/components/watermark/src/watermark.ts b/packages/components/watermark/src/watermark.ts
new file mode 100644
index 0000000000..da23dfd9c6
--- /dev/null
+++ b/packages/components/watermark/src/watermark.ts
@@ -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, Array]),
+ default: 'Element Plus',
+ },
+ /**
+ * @description Text style
+ */
+ font: {
+ type: definePropType(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
+export type WatermarkInstance = InstanceType
diff --git a/packages/components/watermark/src/watermark.vue b/packages/components/watermark/src/watermark.vue
new file mode 100644
index 0000000000..9a7acfb423
--- /dev/null
+++ b/packages/components/watermark/src/watermark.vue
@@ -0,0 +1,225 @@
+
+
+
+
+
+
+
diff --git a/packages/components/watermark/style/index.ts b/packages/components/watermark/style/index.ts
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/element-plus/component.ts b/packages/element-plus/component.ts
index 45b5921615..334aa45d18 100644
--- a/packages/element-plus/component.ts
+++ b/packages/element-plus/component.ts
@@ -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[]
diff --git a/typings/components.d.ts b/typings/components.d.ts
index 358d70bd2d..225d56e108 100644
--- a/typings/components.d.ts
+++ b/typings/components.d.ts
@@ -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 {