APIs
参数 |
说明 |
类型 |
默认值 |
必传 |
width |
水印的宽度,默认值为 content 自身的宽度 |
number |
undefined |
false |
height |
水印的高度,默认值为 content 自身的高度 |
number |
undefined |
false |
layout |
水印的布局方式:平行布局 parallel ; 交替布局 alternate |
‘parallel’ | ‘alternate’ |
‘alternate’ |
false |
rotate |
水印绘制时,旋转的角度,单位 ° |
number |
-22 |
false |
zIndex |
追加的水印元素的 z-index |
number |
9 |
false |
image |
图片源,建议使用 2 倍或 3 倍图,优先级高于文字 |
string |
undefined |
false |
content |
水印文字内容 |
string | string[] |
‘’ |
false |
fullscreen |
是否展示全屏 |
boolean |
false |
false |
color |
字体颜色 |
string |
‘rgba(0,0,0,.15)’ |
false |
fontSize |
字体大小,单位px |
number |
16 |
false |
fontWeight |
字体粗细 |
‘normal’ | ‘light’ | ‘weight’ | number |
‘normal’ |
false |
fontFamily |
字体类型 |
string |
‘sans-serif’ |
false |
fontStyle |
字体样式 |
‘none’ | ‘normal’ | ‘italic’ | ‘oblique’ |
‘normal’ |
false |
gap |
水印之间的间距 |
[number, number] |
[100, 100] |
false |
offset |
水印距离容器左上角的偏移量,默认为 gap/2 |
[number, number] |
[50, 50] |
false |
效果如下图:在线预览
创建水印组件Watermark.vue
<script setup lang="ts">
import { unref, shallowRef, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import type { CSSProperties } from 'vue'
interface Props {
width?: number
height?: number
layout?: 'parallel'|'alternate'
rotate?: number
zIndex?: number
image?: string
content?: string|string[]
fullscreen?: boolean
color?: string
fontSize?: number
fontWeight?: 'normal'|'light'|'weight'|number
fontFamily?: string
fontStyle?: 'none'|'normal'|'italic'|'oblique'
gap?: [number, number]
offset?: [number, number]
}
const props = withDefaults(defineProps<Props>(), {
width: undefined,
height: undefined,
layout: 'alternate',
rotate: -22,
zIndex: 9,
image: undefined,
content: '',
fullscreen: false,
color: 'rgba(0,0,0,.15)',
fontSize: 16,
fontWeight: 'normal',
fontFamily: 'sans-serif',
fontStyle: 'normal',
gap: () => [100, 100],
offset: () => [50, 50]
})
const FontGap = 3
const containerRef = shallowRef()
const watermarkRef = shallowRef()
const htmlRef = shallowRef(document.documentElement)
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 BaseSize = computed(() => {
const layoutMap = {
parallel: 1,
alternate: 2
}
return layoutMap[props.layout]
})
const markStyle = computed(() => {
const markStyle: CSSProperties = {
zIndex: props.zIndex ?? 9,
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
backgroundRepeat: 'repeat'
}
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
})
function destroyWatermark () {
if (watermarkRef.value) {
watermarkRef.value.remove()
watermarkRef.value = undefined
}
}
function appendWatermark (base64Url: string, markWidth: number) {
if (containerRef.value && watermarkRef.value) {
stopObservation.value = true
watermarkRef.value.setAttribute(
'style',
getStyleStr({
...markStyle.value,
backgroundImage: `url('${base64Url}')`,
backgroundSize: `${(gapX.value + markWidth) * BaseSize.value}px`
})
)
if (props.fullscreen) {
htmlRef.value.setAttribute('style', 'position: relative')
htmlRef.value.append(watermarkRef.value)
} else {
containerRef.value?.append(watermarkRef.value)
}
setTimeout(() => {
stopObservation.value = false
})
}
}
function toLowercaseSeparator (key: string) {
return key.replace(/([A-Z])/g, '-$1').toLowerCase()
}
function getStyleStr (style: CSSProperties): string {
return Object.keys(style)
.map((key: any) => `${toLowercaseSeparator(key)}: ${style[key]};`)
.join(' ')
}
function 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
const fontSize = props.fontSize
const fontFamily = props.fontFamily
if (!image && ctx.measureText) {
ctx.font = `${Number(fontSize)}px ${fontFamily}`
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) * contents.length + (contents.length - 1) * FontGap
}
return [width ?? defaultWidth, height ?? defaultHeight] as const
}
function getPixelRatio () {
return window.devicePixelRatio || 1
}
function fillTexts (
ctx: CanvasRenderingContext2D,
drawX: number,
drawY: number,
drawWidth: number,
drawHeight: number,
) {
const ratio = getPixelRatio()
const content = props.content
const fontSize = props.fontSize
const fontWeight = props.fontWeight
const fontFamily = props.fontFamily
const fontStyle = props.fontStyle
const color = props.color
const mergedFontSize = Number(fontSize) * ratio
ctx.font = `${fontStyle} normal ${fontWeight} ${mergedFontSize}px/${drawHeight}px ${fontFamily}`
ctx.fillStyle = color
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))
})
}
function 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.value}px`)
canvas.setAttribute('height', `${canvasHeight * BaseSize.value}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
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)
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)
ctx.restore()
rotateWatermark(ctx, alternateRotateX, alternateRotateY, rotate)
fillTexts(ctx, alternateDrawX, alternateDrawY, drawWidth, drawHeight)
appendWatermark(canvas.toDataURL(), markWidth)
}
}
}
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)
}
onMounted(() => {
renderWatermark()
})
watch(
() => [props],
() => {
renderWatermark()
},
{
deep: true,
flush: 'post'
},
)
function reRendering (mutation: MutationRecord, watermarkElement?: HTMLElement) {
let flag = false
if (mutation.removedNodes.length) {
flag = Array.from(mutation.removedNodes).some(node => node === watermarkElement)
}
if (mutation.type === 'attributes' && mutation.target === watermarkElement) {
flag = true
}
return flag
}
function useMutationObserver (target: any, callback: MutationCallback, options: any) {
let observer: MutationObserver | undefined
const cleanup = () => {
if (observer) {
observer.disconnect()
observer = undefined
}
}
const stopWatch = watch(
() => unref(target),
el => {
cleanup()
if (window && el) {
observer = new MutationObserver(callback)
observer!.observe(el, options)
}
},
{ immediate: true }
)
const stop = () => {
cleanup()
stopWatch()
}
return {
stop
}
}
onBeforeUnmount(() => {
destroyWatermark()
})
function onMutate (mutations: MutationRecord[]) {
if (stopObservation.value) {
return
}
mutations.forEach(mutation => {
if (reRendering(mutation, watermarkRef.value)) {
destroyWatermark()
renderWatermark()
}
})
}
useMutationObserver (props.fullscreen ? htmlRef : containerRef, onMutate, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['style', 'class']
})
</script>
<template>
<div ref="containerRef" style="position: relative;">
<slot></slot>
</div>
</template>
在要使用的页面引入
<script setup lang="ts">
import Watermark from './Watermark.vue'
import { reactive, ref } from 'vue'
const model = reactive({
content: 'Vue Amazing UI',
layout: 'alternate',
color: 'rgba(0,0,0,.15)',
fontSize: 16,
fontWeight: 400,
zIndex: 9,
rotate: -22,
gap: [100, 100],
offset: [50, 50]
})
const layoutOptions = [
{
label: 'alternate',
value: 'alternate'
},
{
label: 'parallel',
value: 'parallel'
}
]
const show = ref(false)
</script>
<template>
<div>
<h1>Watermark 水印</h1>
<h2 class="mt30 mb10">基本使用</h2>
<Watermark content="Vue Amazing UI">
<div style="height: 360px" />
</Watermark>
<h2 class="mt30 mb10">平行布局水印</h2>
<Watermark layout="parallel" content="Vue Amazing UI">
<div style="height: 360px" />
</Watermark>
<h2 class="mt30 mb10">多行水印</h2>
<h3 class="mb10">通过 content 设置 字符串数组 指定多行文字水印内容。</h3>
<Watermark :content="['Vue Amazing UI', 'Hello World']">
<div style="height: 400px" />
</Watermark>
<h2 class="mt30 mb10">图片水印</h2>
<h3 class="mb10">通过 image 指定图片地址。为保证图片高清且不被拉伸,请设置 width 和 height, 并上传至少两倍的宽高的 logo 图片地址。</h3>
<Watermark
:height="30"
:width="130"
image="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*lkAoRbywo0oAAAAAAAAAAAAADrJ8AQ/original">
<div style="height: 360px" />
</Watermark>
<h2 class="mt30 mb10">全屏幕水印</h2>
<Watermark v-if="show" fullscreen content="Vue Amazing UI"></Watermark>
<Switch v-model:checked="show" />
<h2 class="mt30 mb10">自定义配置</h2>
<h3 class="mb10">通过自定义参数配置预览水印效果。</h3>
<Flex>
<Watermark v-bind="model">
<p class="u-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.
</p>
<p class="u-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.
</p>
<p class="u-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.
</p>
<img
style=" position: relative; z-index: 1; width: 100%; max-width: 800px;"
src="https://cdn.jsdelivr.net/gh/themusecatcher/[email protected]/6.jpg"
alt="示例图片"
/>
</Watermark>
<Flex
style="
width: 25%;
flex-shrink: 0;
border-left: 1px solid #eee;
padding-left: 20px;
margin-left: 20px;
"
vertical
gap="middle"
>
<p>Content</p>
<Input v-model:value="model.content" />
<p>Layout</p>
<Radio :options="layoutOptions" v-model:value="model.layout" />
<p>Color</p>
<Input v-model:value="model.color" />
<p>FontSize</p>
<Slider v-model:value="model.fontSize" :step="1" :min="0" :max="100" />
<p>FontWeight</p>
<InputNumber v-model:value="model.fontWeight" :step="100" :min="100" :max="1000" />
<p>zIndex</p>
<Slider v-model:value="model.zIndex" :step="1" :min="0" :max="100" />
<p>Rotate</p>
<Slider v-model:value="model.rotate" :step="1" :min="-180" :max="180" />
<p>Gap</p>
<Space style="display: flex" align="baseline">
<InputNumber v-model:value="model.gap[0]" :min="0" placeholder="gapX" />
<InputNumber v-model:value="model.gap[1]" :min="0" placeholder="gapY" />
</Space>
<p>Offset</p>
<Space style="display: flex" align="baseline">
<InputNumber v-model:value="model.offset[0]" :min="0" placeholder="offsetLeft" />
<InputNumber v-model:value="model.offset[1]" :min="0" placeholder="offsetTop" />
</Space>
</Flex>
</Flex>
</div>
</template>
<style>
.u-paragraph {
margin-bottom: 1em;
color: rgba(0, 0, 0, .88);
word-break: break-word;
line-height: 1.5714285714285714;
}
</style>