效果如下图:在线预览
APIs
Watermark
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
width | 水印的宽度,默认为 content 自身的宽度,单位 px |
number | undefined |
height | 水印的高度,默认为 content 自身的高度,单位 px |
number | undefined |
layout | 水印的布局方式:平行布局 parallel ; 交替布局 alternate |
‘parallel’ | ‘alternate’ | ‘alternate’ |
rotate | 水印绘制时,旋转的角度,单位 ° |
number | -22 |
zIndex | 追加的水印元素的 z-index |
number | 90 |
image | 图片源,建议使用 2 倍或 3 倍图,优先级高于文字 |
string | undefined |
content | 水印文字内容 | string | string[] | undefined |
fullscreen | 是否展示全屏 | boolean | false |
textStyle | 水印文字样式 | [Font] | { color: ‘rgba(0, 0, 0, 0.15)’, fontSize: 16, fontWeight: ‘normal’, fontFamily: ‘sans-serif’, fontStyle: ‘normal’ } |
gap | 水印之间的间距 | [number, number] | [100, 100] |
offset | 水印距离容器左上角的偏移量,默认为 gap/2 |
[number, number] | [50, 50] |
Font Type
名称 | 说明 | 类型 | 默认值 |
---|---|---|---|
color | 字体颜色 | string | ‘rgba(0, 0, 0, 0.15)’ |
fontSize | 字体大小,单位 px |
number | 16 |
fontWeight | 字体粗细 | ‘normal’ | ‘light’ | ‘weight’ | number | ‘normal’ |
fontFamily | 字体类型 | string | ‘sans-serif’ |
fontStyle | 字体样式 | ‘none’ | ‘normal’ | ‘italic’ | ‘oblique’ | ‘normal’ |
创建水印组件Watermark.vue
其中引入使用了以下工具函数:
<script setup lang="ts">
import { shallowRef, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import type { CSSProperties } from 'vue'
import { useMutationObserver } from '../utils'
interface Font {
color?: string // 字体颜色,默认 'rgba(0, 0, 0, 0.15)'
fontSize?: number // 字体大小,单位 px,默认 16
fontWeight?: 'normal' | 'light' | 'weight' | number // 字体粗细,默认 'normal'
fontFamily?: string // 字体类型,默认 'sans-serif'
fontStyle?: 'none' | 'normal' | 'italic' | 'oblique' // 字体样式,默认 'normal'
}
interface Props {
width?: number // 水印的宽度,默认为 content 自身的宽度,单位 px
height?: number // 水印的高度,默认为 content 自身的高度,单位 px
layout?: 'parallel' | 'alternate' // 布局方式:平行布局,交替布局
rotate?: number // 水印绘制时,旋转的角度,单位 deg
zIndex?: number // 追加的水印元素的 z-index
image?: string // 图片源,建议使用 2 倍或 3 倍图,优先级高于文字
content?: string | string[] // 水印文字内容
fullscreen?: boolean // 是否展示全屏
textStyle?: Font // 水印文字样式
gap?: [number, number] // 水印之间的间距
offset?: [number, number] // 水印距离容器左上角的偏移量,默认为 gap / 2
}
const props = withDefaults(defineProps<Props>(), {
width: undefined,
height: undefined,
layout: 'alternate',
rotate: -22,
zIndex: 90,
image: undefined,
content: undefined,
fullscreen: false,
textStyle: () => ({
color: 'rgba(0, 0, 0, 0.15)',
fontSize: 16,
fontWeight: 'normal',
fontFamily: 'sans-serif',
fontStyle: 'normal'
}),
gap: () => [100, 100],
offset: () => [50, 50]
})
const FontGap = 3
// 和 ref() 不同,浅层 ref 的内部值将会原样存储和暴露,并且不会被深层递归地转为响应式。只有对 .value 的访问是响应式的。
const containerRef = shallowRef() // ref() 的浅层作用形式
const watermarkRef = shallowRef()
const htmlRef = shallowRef(document.documentElement) // <html></html>元素
const isDark = shallowRef(htmlRef.value.classList.contains('dark')) // 是否开启暗黑模式
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(() => {
// Base size of the canvas, 1 for parallel layout and 2 for alternate layout
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'
}
if (isDark.value) {
markStyle.filter = 'invert(1) hue-rotate(180deg)'
}
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
})
watch(
() => [props],
() => {
renderWatermark()
},
{
deep: true, // 强制转成深层侦听器
flush: 'post' // 在侦听器回调中访问被 Vue 更新之后的 DOM
}
)
onMounted(() => {
renderWatermark()
})
onBeforeUnmount(() => {
destroyWatermark()
})
// 监听是否开启暗黑模式,自动反转水印颜色
useMutationObserver(
htmlRef,
() => {
isDark.value = htmlRef.value.classList.contains('dark')
destroyWatermark()
renderWatermark()
},
{ attributeFilter: ['class'] }
)
// 防止用户修改/隐藏水印
useMutationObserver(props.fullscreen ? htmlRef : containerRef, onMutate, {
subtree: true, // 监听以 target 为根节点的整个子树
childList: true, // 监听 target 节点中发生的节点的新增与删除
attributes: true, // 观察所有监听的节点属性值的变化
attributeFilter: ['style', 'class'] // 声明哪些属性名会被监听的数组。如果不声明该属性,所有属性的变化都将触发通知。
})
function onMutate(mutations: MutationRecord[]) {
if (stopObservation.value) {
return
}
mutations.forEach((mutation: MutationRecord) => {
if (reRendering(mutation, watermarkRef.value)) {
destroyWatermark()
renderWatermark()
}
})
}
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
})
}
}
// converting camel-cased strings to be lowercase and link it with Separator
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(' ')
}
/*
获取水印宽高
图片时默认宽高: [120, 64]
文本时宽高: 由文本内容的宽高计算得出
*/
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.textStyle.fontSize ?? 16
const fontFamily = props.textStyle.fontFamily ?? 'sans-serif'
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
}
// 当前显示设备的物理像素分辨率与 CSS 像素分辨率之比
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.textStyle.fontSize ?? 16
const fontWeight = props.textStyle.fontWeight ?? 'normal'
const fontFamily = props.textStyle.fontFamily ?? 'sans-serif'
const fontStyle = props.textStyle.fontStyle ?? 'normal'
const color = props.textStyle.color ?? 'rgba(0, 0, 0, 0.15)'
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
// 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)
}
}
}
// Rotate with the watermark as the center point
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
function 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
}
</script>
<template>
<div ref="containerRef" style="position: relative">
<slot></slot>
</div>
</template>
在要使用的页面引入
其中引入使用了以下组件:
- Vue3 栅格(Grid)
- Vue3弹性布局(Flex)
- Vue3 输入框(Input)
- Vue3 数字输入框(InputNumber)
- Vue3 滑动输入条(Slider)
- Vue3 开关(Switch)
<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, 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>{
{ $route.name }} {
{ $route.meta.title }}</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="show" />
<h2 class="mt30 mb10">水印配置器</h2>
<h3 class="mb10">通过自定义参数配置预览水印效果。</h3>
<Row :gutter="24">
<Col :span="18">
<Watermark v-bind="model">
<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="max-width: 100%"
src="https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.0.5/6.jpg"
alt="示例图片"
/>
</Watermark>
</Col>
<Col :span="6">
<Flex vertical :gap="12">
Content:<Input v-model:value="model.content" /> Layout:<Radio
:options="layoutOptions"
v-model:value="model.layout"
button
/>
Color:<Input v-model:value="model.color" /> FontSize:<Slider
v-model:value="model.fontSize"
:step="1"
:min="0"
:max="100"
/>
FontWeight:<InputNumber v-model:value="model.fontWeight" :step="100" :min="100" :max="1000" /> zIndex:<Slider
v-model:value="model.zIndex"
:step="1"
:min="0"
:max="100"
/>
Rotate:<Slider v-model:value="model.rotate" :step="1" :min="-180" :max="180" />
Gap:
<Flex>
<InputNumber v-model:value="model.gap[0]" :min="0" placeholder="gapX" />
<InputNumber v-model:value="model.gap[1]" :min="0" placeholder="gapY" />
</Flex>
Offset:
<Flex>
<InputNumber v-model:value="model.offset[0]" :min="0" placeholder="offsetLeft" />
<InputNumber v-model:value="model.offset[1]" :min="0" placeholder="offsetTop" />
</Flex>
</Flex>
</Col>
</Row>
</div>
</template>
<style>
.u-paragraph {
margin-bottom: 1em;
color: rgba(0, 0, 0, 0.88);
word-break: break-word;
line-height: 1.5714285714285714;
}
</style>