支持自定义水印
背景
同花顺手炒-资产分析页面,日活在 80w 以上,所以这个页面每天的分享次数十分巨大,所以有很多用户希望能提供自定义水印的功能。
开发方案
- 设置完成自定义水印后,直接将水印作为背景文字,然后脱离文档流,给定位到指定位置就行
- 使用 canvas 实现,作为特定位置的背景图片
需要注意的点:
- 分享到外部的链接,被可能被人手动删除 dom 节点,从而移除自定义水印
- 分享到外部的链接,水印节点可能被设置透明度为 0
所以综合下来,决定采用 canvas 实现,并结合 mutaionObserver 防止用户篡改水印
通用功能抽离
具体实现
ts
export interface WaterMarkBaseOptions {
/** 水印文案,默认 xialuxiaohuo */
text?: string
/** 水印字体大小,默认 12px */
fontSize?: number
/** 水印文字倾斜角度, 默认 -45deg */
rotate?: number
/** 水印之间的间距倍数,默认一倍 */
gap?: number
/** 水印文字颜色 */
fontColor?: string
}
export type UseWaterMarkOptions = Required<WaterMarkBaseOptions>
/**
* 获取水印图片信息
* @param options 配置参数
* @returns
*/
export function getWaterMark(options: UseWaterMarkOptions): WaterMarkOptions {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
/** 当前设备像素密度 */
const devicePixelRatio = window.devicePixelRatio || 1
// 计算水印文案字体占据的像素宽度
const { width } = ctx.measureText(options.text)
const canvasSize = width + options.gap * devicePixelRatio
const canvasTranslate = canvasSize / 2
canvas.width = canvasSize
canvas.height = canvasSize
ctx.translate(canvasTranslate, canvasTranslate)
ctx.rotate((options.rotate * Math.PI) / 180)
ctx.font = `${options.fontSize * devicePixelRatio}px serif`
ctx.fillStyle = options.fontColor
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(options.text, 0, 0)
return {
/** 水印图片,base64 格式 */
base64: canvas.toDataURL(),
/** 水印图片大小 */
size: canvasSize,
/** 水印图片宽度 */
styleSize: canvasSize / devicePixelRatio,
}
}
ts
/**
* 监听使用节点被修改的情况然后重新设置水印
* @param parentDom 水印挂载父节点
* @param options 水印参数
* @returns
*/
export function watchWaterMarkChange(
parentDom: HTMLDivElement,
watermarkDom: HTMLDivElement | null,
options: WaterMarkOptions,
) {
const ob = new MutationObserver((entries) => {
for (const entry of entries) {
// 遍历被删除的节点
for (const dom of entry.removedNodes) {
// 如果被删除的节点是水印节点,重新设置水印节点
if (dom === watermarkDom) {
setWatermark(parentDom, watermarkDom, options)
return
}
}
// 修改水印节点的属性,例如修改水印节点的 css样式,比如透明度设置为 0 的情况
if (entry.target === watermarkDom) {
setWatermark(parentDom, watermarkDom, options)
}
}
})
return ob
}
ts
/**
* 设置水印到父节点上去
* @param parentDom 水印挂载父节点
* @param options 水印参数
* @returns
*/
export function setWatermark(
parentDom: HTMLDivElement,
watermarkDom: HTMLDivElement | null,
options: WaterMarkOptions,
) {
if (!parentDom) return
if (watermarkDom) {
watermarkDom.remove()
}
const { base64, styleSize } = options
watermarkDom = document.createElement('div')
watermarkDom.style.backgroundImage = `url(${base64})`
watermarkDom.style.backgroundSize = `${styleSize}px`
watermarkDom.style.backgroundRepeat = 'repeat'
watermarkDom.style.width = '100%'
watermarkDom.style.height = '100%'
watermarkDom.style.zIndex = '99999'
watermarkDom.style.position = 'absolute'
watermarkDom.style.pointerEvents = 'none'
watermarkDom.style.inset = '0'
parentDom.appendChild(watermarkDom)
}
通用功能完整代码
ts
export interface WaterMarkBaseOptions {
/** 水印文案,默认 xialuxiaohuo */
text?: string
/** 水印字体大小,默认 12px */
fontSize?: number
/** 水印文字倾斜角度, 默认 -45deg */
rotate?: number
/** 水印之间的间距倍数,默认一倍 */
gap?: number
/** 水印文字颜色 */
fontColor?: string
}
export interface WaterMarkOptions {
/** 水印图片,base64 格式 */
base64: string
/** 水印图片大小 */
size: number
/** 水印图片宽度 */
styleSize: number
/** 水印id */
waterMarkId: string
}
/**
* 获取水印图片信息
* @param options 配置参数
* @returns
*/
export function getWaterMark(options: Required<WaterMarkBaseOptions>): WaterMarkOptions {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
/** 当前设备像素密度 */
const devicePixelRatio = window.devicePixelRatio || 1
// 计算水印文案字体占据的像素宽度
const { width } = ctx.measureText(options.text)
const canvasSize = width + options.gap * devicePixelRatio
const canvasTranslate = canvasSize / 2
canvas.width = canvasSize
canvas.height = canvasSize
ctx.translate(canvasTranslate, canvasTranslate)
ctx.rotate((options.rotate * Math.PI) / 180)
ctx.font = `${options.fontSize * devicePixelRatio}px serif`
ctx.fillStyle = options.fontColor
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(options.text, 0, 0)
const waterMarkId = `watermark_${Date.now()}`
return {
/** 水印图片,base64 格式 */
base64: canvas.toDataURL(),
/** 水印图片大小 */
size: canvasSize,
/** 水印图片宽度 */
styleSize: canvasSize / devicePixelRatio,
/** 水印id */
waterMarkId,
}
}
/**
* 设置水印到父节点上去
* @param parentDom 水印挂载父节点
* @param options 水印参数
* @returns
*/
export function setWatermark(parentDom: HTMLDivElement, options: WaterMarkOptions) {
if (!parentDom) return
const { base64, styleSize, waterMarkId } = options
const watermarkDom = document.getElementById(waterMarkId)
if (watermarkDom) {
watermarkDom.remove()
}
const div = document.createElement('div')
div.style.backgroundImage = `url(${base64})`
div.style.backgroundSize = `${styleSize}px`
div.style.backgroundRepeat = 'repeat'
div.style.width = '100%'
div.style.height = '100%'
div.style.zIndex = '99999'
div.style.position = 'absolute'
div.style.pointerEvents = 'none'
div.style.inset = '0'
div.id = waterMarkId
parentDom.appendChild(div)
}
/**
* 监听使用节点被修改的情况然后重新设置水印
* @param parentDom 水印挂载父节点
* @param options 水印参数
* @returns
*/
export function watchWaterMarkChange(parentDom: HTMLDivElement, options: WaterMarkOptions) {
const { waterMarkId } = options
const watermarkDom = document.getElementById(waterMarkId)
const ob = new MutationObserver((entries) => {
for (const entry of entries) {
// 遍历被删除的节点
for (const dom of entry.removedNodes) {
// 如果被删除的节点是水印节点,重新设置水印节点
if (dom === watermarkDom) {
setWatermark(parentDom, options)
return
}
}
// 修改水印节点的属性,例如修改水印节点的 css样式,比如透明度设置为 0 的情况
if (entry.target === watermarkDom) {
setWatermark(parentDom, options)
}
}
})
return ob
}
组件实现
tsx
import { memo, useCallback, useEffect, useRef } from 'react'
import { WaterMarkBaseOptions, WaterMarkOptions, getWaterMark, setWatermark, watchWaterMarkChange } from './utils'
type WaterMarkProps = WaterMarkBaseOptions & {
/** 水印覆盖内容 */
children: React.ReactNode
/** 自定义水印样式 */
style?: React.CSSProperties
/** 自定义水印类名 */
classname?: string
}
function WaterMark(props: WaterMarkProps) {
const { text = 'xialuxiaohuo', fontSize = 16, rotate = -45, gap = 10, fontColor = '#999', children } = props
const parentRef = useRef<HTMLDivElement>(null)
const ob = useRef<MutationObserver>()
const watermarkOptions = useRef<WaterMarkOptions>()
useEffect(() => {
getWatermarkOptions()
resetWatermark()
ob.current = watchWaterMarkChange(parentRef.current as HTMLDivElement, watermarkOptions.current as WaterMarkOptions)
ob.current.observe(parentRef.current as HTMLDivElement, {
attributes: true,
childList: true,
subtree: true,
})
return () => {
ob.current?.disconnect()
const id = watermarkOptions.current?.waterMarkId || ''
if (id) {
document.getElementById?.(id)?.remove()
}
}
}, [text, fontSize, rotate, gap, fontColor])
const getWatermarkOptions = useCallback(() => {
watermarkOptions.current = getWaterMark({
fontSize,
rotate,
text,
gap,
fontColor,
})
}, [text, fontSize, rotate, gap, fontColor])
const resetWatermark = useCallback(() => {
setWatermark(parentRef.current as HTMLDivElement, watermarkOptions.current as WaterMarkOptions)
}, [])
return (
<div
ref={parentRef}
className={`watermark-contain ${props?.classname}`}
style={{ position: 'relative', ...props?.style }}>
{children}
</div>
)
}
export default memo(WaterMark)
vue
<template>
<div ref="parentRef" :class="classname" :style="style">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { CSSProperties, HTMLAttributes, computed, onMounted, onUnmounted, ref, watch } from 'vue';
import {
WaterMarkBaseOptions,
WaterMarkOptions,
getWaterMark,
setWatermark,
watchWaterMarkChange,
} from './utils';
type WaterMarkProps = WaterMarkBaseOptions & {
/** 自定义水印样式 */
style?: CSSProperties;
/** 自定义水印类名 */
class?: string;
};
const props = withDefaults(defineProps<WaterMarkProps>(), {
text: 'xialuxiaohuo',
fontSize: 16,
rotate: -45,
gap: 10,
fontColor: '#999',
class: '',
style: () => ({}),
})
const style = computed<HTMLAttributes['style']>(() => {
return {
position: 'relative',
...props?.style,
}
})
const classname = computed(() => {
return `watermark-contain ${props?.class}`
})
const watermarkOptions = ref<WaterMarkOptions>()
const parentRef = ref<HTMLDivElement>();
const ob = ref<MutationObserver>();
const getWatermarkOptions = () => {
const { fontSize,
rotate,
text,
gap,
fontColor, } = props;
watermarkOptions.value = getWaterMark({
fontSize,
rotate,
text,
gap,
fontColor,
});
};
const resetWatermark = () => setWatermark(
parentRef.value as HTMLDivElement,
watermarkOptions.value as WaterMarkOptions
);
onMounted(() => {
observe()
})
const observe = () => {
getWatermarkOptions();
resetWatermark();
ob.value = watchWaterMarkChange(
parentRef.value as HTMLDivElement,
watermarkOptions.value as WaterMarkOptions
);
ob.value?.observe(parentRef.value as HTMLDivElement, {
attributes: true,
childList: true,
subtree: true,
});
}
const disconnect = () => {
ob.value?.disconnect();
const id = watermarkOptions.value?.waterMarkId || '';
if (id) {
document.getElementById?.(id)?.remove();
}
}
onUnmounted(() => disconnect())
watch(() => [
props.fontSize,
props.rotate,
props.text,
props.gap,
props.fontColor,
], () => {
disconnect()
observe()
})
</script>
<style scoped></style>