Vue3水印(Watermark)

简介: 这是一个基于Vue.js的水印组件库,提供了丰富的自定义选项,包括水印的布局、旋转角度、间距等。组件支持文字和图片水印,并可通过API调整样式和位置。

效果如下图:在线预览

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

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>

在要使用的页面引入

其中引入使用了以下组件:

<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&apos;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&apos; 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>
相关文章
|
2月前
|
JavaScript 前端开发 安全
Vue 3
Vue 3以组合式API、Proxy响应式系统和全面TypeScript支持,重构前端开发范式。性能优化与生态协同并进,兼顾易用性与工程化,引领Web开发迈向高效、可维护的新纪元。(238字)
508 139
|
2月前
|
缓存 JavaScript 算法
Vue 3性能优化
Vue 3 通过 Proxy 和编译优化提升性能,但仍需遵循最佳实践。合理使用 v-if、key、computed,避免深度监听,利用懒加载与虚拟列表,结合打包优化,方可充分发挥其性能优势。(239字)
226 1
|
7月前
|
缓存 JavaScript PHP
斩获开发者口碑!SnowAdmin:基于 Vue3 的高颜值后台管理系统,3 步极速上手!
SnowAdmin 是一款基于 Vue3/TypeScript/Arco Design 的开源后台管理框架,以“清新优雅、开箱即用”为核心设计理念。提供角色权限精细化管理、多主题与暗黑模式切换、动态路由与页面缓存等功能,支持代码规范自动化校验及丰富组件库。通过模块化设计与前沿技术栈(Vite5/Pinia),显著提升开发效率,适合团队协作与长期维护。项目地址:[GitHub](https://github.com/WANG-Fan0912/SnowAdmin)。
983 5
|
3月前
|
开发工具 iOS开发 MacOS
基于Vite7.1+Vue3+Pinia3+ArcoDesign网页版webos后台模板
最新版研发vite7+vue3.5+pinia3+arco-design仿macos/windows风格网页版OS系统Vite-Vue3-WebOS。
390 11
|
2月前
|
JavaScript 安全
vue3使用ts传参教程
Vue 3结合TypeScript实现组件传参,提升类型安全与开发效率。涵盖Props、Emits、v-model双向绑定及useAttrs透传属性,建议明确声明类型,保障代码质量。
263 0
|
4月前
|
缓存 前端开发 大数据
虚拟列表在Vue3中的具体应用场景有哪些?
虚拟列表在 Vue3 中通过仅渲染可视区域内容,显著提升大数据列表性能,适用于 ERP 表格、聊天界面、社交媒体、阅读器、日历及树形结构等场景,结合 `vue-virtual-scroller` 等工具可实现高效滚动与交互体验。
446 1
|
4月前
|
缓存 JavaScript UED
除了循环引用,Vue3还有哪些常见的性能优化技巧?
除了循环引用,Vue3还有哪些常见的性能优化技巧?
258 0
|
5月前
|
JavaScript
vue3循环引用自已实现
当渲染大量数据列表时,使用虚拟列表只渲染可视区域的内容,显著减少 DOM 节点数量。
136 0
|
7月前
|
JavaScript API 容器
Vue 3 中的 nextTick 使用详解与实战案例
Vue 3 中的 nextTick 使用详解与实战案例 在 Vue 3 的日常开发中,我们经常需要在数据变化后等待 DOM 更新完成再执行某些操作。此时,nextTick 就成了一个不可或缺的工具。本文将介绍 nextTick 的基本用法,并通过三个实战案例,展示它在表单验证、弹窗动画、自动聚焦等场景中的实际应用。
605 17
|
7月前
|
JavaScript 前端开发 API
Vue 2 与 Vue 3 的区别:深度对比与迁移指南
Vue.js 是一个用于构建用户界面的渐进式 JavaScript 框架,在过去的几年里,Vue 2 一直是前端开发中的重要工具。而 Vue 3 作为其升级版本,带来了许多显著的改进和新特性。在本文中,我们将深入比较 Vue 2 和 Vue 3 的主要区别,帮助开发者更好地理解这两个版本之间的变化,并提供迁移建议。 1. Vue 3 的新特性概述 Vue 3 引入了许多新特性,使得开发体验更加流畅、灵活。以下是 Vue 3 的一些关键改进: 1.1 Composition API Composition API 是 Vue 3 的核心新特性之一。它改变了 Vue 组件的代码结构,使得逻辑组
1765 0