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>
相关文章
|
13天前
|
缓存 JavaScript UED
Vue3中v-model在处理自定义组件双向数据绑定时有哪些注意事项?
在使用`v-model`处理自定义组件双向数据绑定时,要仔细考虑各种因素,确保数据的准确传递和更新,同时提供良好的用户体验和代码可维护性。通过合理的设计和注意事项的遵循,能够更好地发挥`v-model`的优势,实现高效的双向数据绑定效果。
117 64
|
13天前
|
JavaScript 前端开发 API
Vue 3 中 v-model 与 Vue 2 中 v-model 的区别是什么?
总的来说,Vue 3 中的 `v-model` 在灵活性、与组合式 API 的结合、对自定义组件的支持等方面都有了明显的提升和改进,使其更适应现代前端开发的需求和趋势。但需要注意的是,在迁移过程中可能需要对一些代码进行调整和适配。
|
13天前
|
前端开发 JavaScript 测试技术
Vue3中v-model在处理自定义组件双向数据绑定时,如何避免循环引用?
Web 组件化是一种有效的开发方法,可以提高项目的质量、效率和可维护性。在实际项目中,要结合项目的具体情况,合理应用 Web 组件化的理念和技术,实现项目的成功实施和交付。通过不断地探索和实践,将 Web 组件化的优势充分发挥出来,为前端开发领域的发展做出贡献。
24 8
|
13天前
|
存储 JavaScript 数据管理
除了provide/inject,Vue3中还有哪些方式可以避免v-model的循环引用?
需要注意的是,在实际开发中,应根据具体的项目需求和组件结构来选择合适的方式来避免`v-model`的循环引用。同时,要综合考虑代码的可读性、可维护性和性能等因素,以确保系统的稳定和高效运行。
17 1
|
13天前
|
JavaScript
Vue3中使用provide/inject来避免v-model的循环引用
`provide`和`inject`是 Vue 3 中非常有用的特性,在处理一些复杂的组件间通信问题时,可以提供一种灵活的解决方案。通过合理使用它们,可以帮助我们更好地避免`v-model`的循环引用问题,提高代码的质量和可维护性。
25 1
|
13天前
|
JavaScript
在 Vue 3 中,如何使用 v-model 来处理自定义组件的双向数据绑定?
需要注意的是,在实际开发中,根据具体的业务需求和组件设计,可能需要对上述步骤进行适当的调整和优化,以确保双向数据绑定的正确性和稳定性。同时,深入理解 Vue 3 的响应式机制和组件通信原理,将有助于更好地运用 `v-model` 实现自定义组件的双向数据绑定。
|
22天前
|
JavaScript 索引
Vue 3.x 版本中双向数据绑定的底层实现有哪些变化
从Vue 2.x的`Object.defineProperty`到Vue 3.x的`Proxy`,实现了更高效的数据劫持与响应式处理。`Proxy`不仅能够代理整个对象,动态响应属性的增删,还优化了嵌套对象的处理和依赖追踪,减少了不必要的视图更新,提升了性能。同时,Vue 3.x对数组的响应式处理也更加灵活,简化了开发流程。
|
1月前
|
JavaScript 数据管理 Java
在 Vue 3 中使用 Proxy 实现数据双向绑定的性能如何?
【10月更文挑战第23天】Vue 3中使用Proxy实现数据双向绑定在多个方面都带来了性能的提升,从更高效的响应式追踪、更好的初始化性能、对数组操作的优化到更优的内存管理等,使得Vue 3在处理复杂的应用场景和大量数据时能够更加高效和稳定地运行。
53 1
|
17天前
|
JavaScript 前端开发 API
从Vue 2到Vue 3的演进
从Vue 2到Vue 3的演进
31 0
|
17天前
|
JavaScript 前端开发 API
Vue.js响应式原理深度解析:从Vue 2到Vue 3的演进
Vue.js响应式原理深度解析:从Vue 2到Vue 3的演进
45 0