Vue3水印(Watermark)

75 篇文章 4 订阅
72 篇文章 3 订阅

效果如下图:在线预览

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

APIs

Watermark

参数说明类型默认值
width水印的宽度,默认为 content 自身的宽度,单位 pxnumberundefined
height水印的高度,默认为 content 自身的高度,单位 pxnumberundefined
layout水印的布局方式:平行布局 parallel; 交替布局 alternate‘parallel’ | ‘alternate’‘alternate’
rotate水印绘制时,旋转的角度,单位 °number-22
zIndex追加的水印元素的 z-indexnumber90
image图片源,建议使用 2 倍或 3 倍图,优先级高于文字stringundefined
content水印文字内容string | string[]undefined
fullscreen是否展示全屏booleanfalse
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字体大小,单位 pxnumber16
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>
  • 14
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 8
    评论
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值