Vue3水印(Watermark)

效果如下图:

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

在线预览

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
fixed是否固定水印,仅当启用全屏水印时生效booleantrue
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’

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>

在要使用的页面引入

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

<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>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

theMuseCatcher

您的支持是我创作的最大动力!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值