效果如下图:
在线预览
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 |
fixed | 是否固定水印,仅当启用全屏水印时生效 | boolean | true |
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’ |
Slots
名称 | 说明 | 类型 |
---|---|---|
default | 自定义内容 | v-slot:default |
创建水印组件Watermark.vue
其中引入使用了以下工具函数:
<script setup lang="ts">
import { shallowRef, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import type { CSSProperties } from 'vue'
import { useMutationObserver } from 'components/utils'
export 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'
}
export 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 // 是否启用全屏水印
fixed?: 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,
fixed: true,
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 fullscreenFixed = computed(() => {
return props.fullscreen && props.fixed
})
const markStyle = computed(() => {
const markStyle: CSSProperties = {
zIndex: props.zIndex ?? 9,
position: fullscreenFixed.value ? 'fixed' : '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)
- Vue3颜色选择器(ColorPicker)
<script setup lang="ts">
import Watermark from './Watermark.vue'
import { reactive, ref } from 'vue'
const show = ref(false)
const fixed = ref(true)
const imageModel = reactive({
rotate: 0,
layout: 'alternate'
})
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'
}
]
</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;另支持设置图片布局方式 layout
和旋转角度 rotate 等</h3
>
<Flex>
<Flex vertical :gap="8">
Layout: <Radio :options="layoutOptions" v-model:value="imageModel.layout" button />
</Flex>
<Flex vertical :gap="8" :width="240">
Rotate: <Slider v-model:value="imageModel.rotate" :step="1" :min="-180" :max="180" />
</Flex>
</Flex>
<Watermark
:height="48"
:width="48"
:layout="imageModel.layout"
:rotate="imageModel.rotate"
image="https://avatars.githubusercontent.com/u/46012811?v=4"
>
<div style="height: 360px" />
</Watermark>
<h2 class="mt30 mb10">全屏幕水印</h2>
<Watermark v-if="show" fullscreen :fixed="fixed" content="Vue Amazing UI"></Watermark>
<Space align="center"> Fullscreen: <Switch v-model="show" /> Fixed: <Switch v-model="fixed" /> </Space>
<h2 class="mt30 mb10">水印配置器</h2>
<h3 class="mb10">通过自定义参数配置预览水印效果</h3>
<Row :gutter="24">
<Col :span="18">
<Watermark v-bind="model">
<p class="paragraph-text">
《麦田里的守望者》(英语:The Catcher in the
Rye),为美国作家J.D.塞林格于1951年发表的长篇小说。这部有争议的作品原本是面向成年读者的,但迅速因其青春期焦虑和隔绝的主题而在青少年读者中流行。
</p>
<p class="paragraph-text">
该书以主人公霍尔顿·考菲尔德第一人称口吻讲述自己被学校开除学籍后在纽约城游荡将近两昼夜,企图逃出虚伪的成人世界、去寻求纯洁与真理的经历与感受。
</p>
<p class="paragraph-text">
该书于1951年出版之后,立刻引起巨大的轰动,受到读者──特别是青年人──的热烈的欢迎,被翻译为多国语版。小说每年大约有250,000本售出、总计为6500万本。《时代杂志》将《麦田里的守望者》列在“2005年度百大英语小说(自1923年起)”榜上,现代图书馆及其读者也将其列在20世纪百大英文小说榜上。赞赏者认为本书用青少年的口吻平铺直叙,增加了作品的感染力,传神地描写主角的内心思维,并说出了青少年不满成年世界充满虚伪欺瞒的心声。批评者则认为书中主角离经叛道,逃学、吸烟、喝酒又满嘴粗话,会给年轻读者带来不良影响。当时许多图书馆及学校将之列为禁书,并被列在America
library
Associations上。但现在这本书却是许多美国学校的指定读物。有的评论家说,它“大大地影响了好几代美国青年”。而且有学者认为,霍尔顿是当代美国文学中最早出现的反英雄形象之一。
</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">
<Flex vertical> Content:<Input v-model:value="model.content" /> </Flex>
<Flex vertical> Layout:<Radio :options="layoutOptions" v-model:value="model.layout" button /> </Flex>
<Flex vertical> Color:<ColorPicker v-model:value="model.color" /> </Flex>
<Flex vertical> FontSize:<Slider v-model:value="model.fontSize" :step="1" :min="0" :max="100" /> </Flex>
<Flex vertical>
FontWeight:<InputNumber v-model:value="model.fontWeight" :step="100" :min="100" :max="1000" />
</Flex>
<Flex vertical> zIndex:<Slider v-model:value="model.zIndex" :step="1" :min="0" :max="100" /> </Flex>
<Flex vertical> Rotate:<Slider v-model:value="model.rotate" :step="1" :min="-180" :max="180" /> </Flex>
<Flex vertical>
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>
</Flex>
</Col>
</Row>
</div>
</template>
<style lang="less" scoped>
.paragraph-text {
margin-bottom: 1em;
font-size: 16px;
color: rgba(0, 0, 0, 0.88);
word-break: break-word;
line-height: 1.5714285714285714;
}
</style>