水印组件的发展史
前言
功能背景
广告后台业务需求,页面需要增加水印,实现水印的全局覆盖,以此来保证数据的安全性。因功能实现难度较高,涉及功能点复杂,所以记录一篇文章,来讲解具体的实现。
说明
本文讲解了web前端实现全局页面增加水印的方式,拓展介绍了图片增加自定义水印的方法。基本实现了高安全度的水印,保证了页面数据的安全性。
目标
- 基础:完成不影响页面功能操作,且安全系数高的页面水印(不可通过控制台删除和控制样式) -- 对应文章第一部分
- 拓展:可增加到图片上的水印,可自定义(可旋转,缩放,拖拽,不限制数量,可为文字或图片的水印) -- 对应文章第二部分
- 难点方法:将web前端代码变成canvas的方法 -- 对应文章第三部分
说明
- 本文通过循序渐进的方式讲解,所以重复代码可能有点多。
- 本文尽可能针对难点性问题讲解,最终所有代码会呈现在底部github链接中
第一部分:页面全局水印
效果展示
演示地址
https://little-littleprogrammer.github.io/npm-components/dist/#/
地址里还有其他的组件,有兴趣的可以看下啊
实现的功能
1、实现页面全局水印,水印斜向平铺布满整个页面
2、不可通过F12的控制台进行对水印canvas元素的删除和样式的更改
具体的实现
说明:
1、本文通过循序渐进的方式讲解,所以重复代码可能有点多。
2、本文尽可能针对难点性问题讲解,最终所有代码会呈现在底部github链接中
水印斜向平铺部分
1、sass写法,简化了css样式的代码
2、先将文字每行铺满,沾满全屏(尽可能多的设置一行多个文字,多行,最后超出的部分设置溢出隐藏)
<template>
<div ref="ref-watermark">
<div class="qm-watermark">
<h1 v-for="item in 16" :key="'line'+item"><span v-for="i in 60" :key="'name'+i">{{name}}</span></h1>
</div>
</div>
</template>
3、设置fixed固定在所有图层的最前面,设置pointer-events: none;
使水印图层可以点击穿透,从而不影响水印图层后面的操作逻辑
<style lang="scss" scoped>
.qm-watermark { // 水印
position: fixed;
z-index: 99999;
top: 0;
bottom: 0;
left: 0;
right: 0;
pointer-events: none;
opacity: 0.1;
font-size: 14px;
font-family: Cursive, serif;
overflow: hidden;
span {
margin-right: 180px;
}
}
</style>
4、对每一行文字设置不同的旋转点,进行旋转,并且设置text-indent进行负向衍生
<style lang="scss" scoped>
.qm-watermark { // 水印
$e:17;
@for $i from 0 to $e {
h1:nth-child(#{$i}){
white-space: nowrap;
transform-origin: (18% * ($i - 1)) 1%;
transform: rotate(-20deg);
text-indent: (-200px * ($i - 1));
}
}
}
</style>
生成canvas部分
大致原理:将html代码转化成svg代码,再转化成canvas
具体的实现请看如何将web代码变成canvas元素
监听控制台元素删除部分
实现的效果
当通过控制台删除元素时,会跳到特殊页面
实现原理
1、水印组件添加slot,将页面元素通过slot插入水印组件中,设置slot默认布局为请勿删除和隐藏水印!!!
<div ref="ref-watermark">
<div class="qm-watermark">
<h1 v-for="item in 16" :key="'line'+item"><span v-for="i in 60" :key="'name'+i">{{name}}</span></h1>
</div>
<slot :name="content">
<div class="error-warning">
<img id="forbidImg" src="../assets/images/forbidden.jpg" alt="" srcset="">
<p>请勿删除和隐藏水印!!!</p>
</div>
</slot>
</div>
2、通过js自带的MutationObserver
对象(监听元素变化)
3、设置监听元素为水印元素
listen_dom($dom) { //
// 观察器的配置(需要观察什么变动)
const config = { attributes: true, childList: true, subtree: true };
// 当观察到变动时执行的回调函数
const callback = (mutationsList, observer) => {
for (const mutation of mutationsList) {
if (mutation.removedNodes[0] === $dom) {
this.content = '';
// 停止观察
observer.disconnect();
}
}
};
// 创建一个观察器实例并传入回调函数
const observer = new MutationObserver(callback);
// 以上述配置开始观察目标节点
observer.observe(this.$refs['ref-watermark'], config);
}
4、当监听到删除的元素为水印canvas时,设置slot为空,并停止观察
for (const mutation of mutationsList) {
if (mutation.removedNodes[0] === $dom) {
this.content = '';
// 停止观察
observer.disconnect();
}
}
监听控制台元素样式更改和隐藏部分
实现的效果
- 设置display,visible,opacity并不会生效
- 通过控制台隐藏元素
实现原理
- 也是通过
MutationObserver
对象,设置观察器的配置,让其只监听canvas的style和class属性{ attributes: true, // 将其配置为侦听属性更改, attributeFilter: ['style', 'class'] // 监听style属性 }
- 当监听到css属性,
display,visible,opacity
发生变化时,则将其强制设置为以下部分,从而达到无法通过样式消除元素的效果if (mutation.type == 'attributes') { console.log('css changed', mutation); $canvas.style.display = 'block'; $canvas.style.opacity = '1'; $canvas.style.visibility = 'visible'; }
- 当隐藏元素,监听到类名的改变时,则设置slot为空,展示默认部分
if (mutation.type == 'attributes' && mutation.attributeName === 'class') { console.log('className changed', mutation); this.content = ''; // 停止观察 observer.disconnect(); }
第二部分:图片水印的实现
效果展示
演示地址:
https://little-littleprogrammer.github.io/npm-components/dist/#/waterMark
(地址里还有其他小组件,有兴趣的可以看下)
实现的功能
- 水印样式可自定义 -- 斜向平铺以及自定义
- 自定义样式可自定义水印的个数,颜色,大小,可以实现水印旋转,拖拽
- 可导出为png格式的图片,文件名为第一个水印的名称
- 可添加图片水印,图片水印可实现旋转拖拽和缩放
具体实现
选择图片部分
- 选择文件后,通过get_file_data方法,将图片转化为base64
- 根据图片的大小,去控制result-container和img-container容器的大小,当超过浏览器宽度时,限制图片为浏览器宽度
<template>
<input type="file" accept="image/*" @change="get_file_data" />
<div class="result-container" ref="ref-result-container">
<div class="img-container" ref="ref-img-container">
<img :src="imgUrl" alt />
</div>
</div>
</template>
get_file_data(data) {
const fileReader = new FileReader();
const $resultContainer = this.$refs['ref-result-container'];
const $imgContainer = this.$refs['ref-img-container'];
fileReader.onload = (e) => {
this.imgUrl = e.target.result;
const _image = new Image();
_image.src = this.imgUrl;
_image.onload = (e) => {
if (e.path[0].width > document.body.clientWidth) {
$resultContainer.style.width =
document.body.clientWidth + 'px';
$imgContainer.style.width =
document.body.clientWidth - 40 + 'px';
} else {
$resultContainer.style.width = e.path[0].width + 'px';
$imgContainer.style.width = e.path[0].width - 40 + 'px';
}
};
};
// readAsDataURL
fileReader.readAsDataURL(data.target.files[0]);
fileReader.onerror = () => {
new Error('blobToBase64 error');
};
},
水印样式部分
layout控制样式,1是斜向平铺,2是自定义样式(自定义样式可添加文字水印和图片水印)
1、斜向平铺
最终效果:
斜向平铺样式主要为css控制,我这里使用的笨办法,设置很多行,一行很多个,对不同的行设置不同的旋转点,旋转。最后溢出的部分overflow:hidden
掉
<div v-else class="qm-watermark">
<p v-for="item in 16" :key="'line'+item">
<span v-for="i in 60" :key="'name'+i">{{markList[0].username}}</span>
</p>
</div>
.qm-watermark {
// 水印
position: absolute;
z-index: 99999;
top: 0;
bottom: 0;
left: 0;
right: 0;
pointer-events: none;
font-family: Cursive, serif;
overflow: hidden;
$e: 17;
@for $i from 0 to $e {
p:nth-child(#{$i}) {
white-space: nowrap;
transform-origin: (18% * ($i - 1)) 1%;
transform: rotate(-20deg);
text-indent: (-10px * ($i - 1));
}
}
span {
margin-right: 30px;
}
}
2、自定义水印部分
自定义样式的难点主要在于水印字体的旋转、移动与缩放
<div v-for="item in markList" :key="item.index">
<li>
<div>
<span>姓名:</span>
<input v-model="item.username" type="text" />
</div>
</li>
<li>
<button
v-if="item.index===markList.length-1 && item.index !==0"
:disabled="form.layout!=='2'"
:class="{'disabled':form.layout!=='2'}"
@click="del_water_mark"
>删除水印</button>
<button
v-if="form.layout=='2'&&item.index===markList.length-1"
:class="{'disabled':form.layout!=='2'}"
@click="add_water_mark"
>添加水印</button>
</li>
</div>
<script>
data() {
return {
markList: [
{
index: 0,
username: '',
color: '#000000',
size: 14
}
],
}
}
</script>
methods:{
add_water_mark() {
this.keyIndex++;
this.markList.push({
index: this.keyIndex,
username: '',
color: '#000000',
size: 14
});
},
del_water_mark() {
this.keyIndex--;
this.markList.pop();
}
}
旋转部分
效果
旋转部分逻辑:
- 右下角旋转图标为svg图标(生成图片是不会存在,在变成canvas的时候,进行了过滤)
- 旋转图标添加
mousedown
事件,点击后,为document
添加mousemove
和mouseup
事件,鼠标弹起时移除 - 当有多个水印时,如何判断点击的是哪个水印,在通过
rotate_name(e)
方法中通过$event
的属性,就能取到父组件与祖父组件,从而判断出来改变的是那个元素。 - 通过
get_origin_transform($dom)
方法获取旋转中心点(因为移动后会改变旋转中心点,所以要每次点击都要获取),然后再通过get_rotate_deg(e)
方法,获取到鼠标的点,旋转中心点,通过一下算法,计算出旋转值,通过$dom.style.transform
设置旋转
代码
rotate_name(e) {
this._parentNode =
e.target.tagName.toLowerCase() === 'svg'
? e.target.parentNode
: e.target.parentNode.parentNode;
this._grentParentNode =
e.target.tagName.toLowerCase() === 'svg'
? e.target.parentNode.parentNode
: e.target.parentNode.parentNode.parentNode;
document.addEventListener('mousemove', this.get_rotate_deg);
document.addEventListener('mouseup', () => {
document.removeEventListener('mousemove', this.get_rotate_deg);
});
},
get_rotate_deg(e) {
const $dom = this._parentNode;
const $domParent = this._grentParentNode;
const transformOption = this.get_origin_transform($domParent);
// 中心点的计算是根据浏览器视口的计算,offset是根据页面元素的位置,所以要减去卷动值
const positonByHtml = {
centerX: Methods.offset($dom).left + $dom.clientWidth / 2 - document.documentElement.scrollLeft,
centerY: Methods.offset($dom).top + $dom.clientHeight / 2 - document.documentElement.scrollTop
};
let x = e.clientX;
let y = e.clientY;
const origin = {
x:
+positonByHtml.centerX +
parseInt(transformOption.translateX || 0),
y:
+positonByHtml.centerY +
parseInt(transformOption.translateY || 0)
}; // 先手动指定当前中心点,也可以根据当前元素的left+width/2 的到x top+height/2 得到y值
// 计算出当前鼠标相对于元素中心点的坐标
x = x - origin.x; // 因为x大于origin.x 是在y轴右边,直接减就行了
y = origin.y - y; // 但是y如果要在x轴上方,它是比origin.y要小的,所以这里就需要反过来
// 然后计算就可以了
const deg = (Math.atan2(y, x) / Math.PI) * 180;
const option = {
moveX: 0,
moveY: 0,
deg: -deg || 0
};
this.set_transform($dom, option);
},
get_origin_transform($dom) {
let old = $dom.style.transform;
const transformOption = {};
if (old) {
old = old.split(' ');
old.forEach((item) => {
transformOption[item.replace(/\((.*)\)/, '')] =
item.replace(/[^0-9-]/g, '');
});
}
return transformOption;
},
set_transform(dom, option) {
Methods.css(dom, {
transform: `rotate(${option.deg}deg) translateX(${option.moveX}px) translateY(${option.moveY}px)`
});
},
具体算法
const positonByHtml = {
centerX: Methods.offset($dom).left + $dom.clientWidth / 2 - document.documentElement.scrollLeft,
centerY: Methods.offset($dom).top + $dom.clientHeight / 2 - document.documentElement.scrollTop
};
let x = e.clientX;
let y = e.clientY;
const origin = {
x:
+positonByHtml.centerX +
parseInt(transformOption.translateX || 0),
y:
+positonByHtml.centerY +
parseInt(transformOption.translateY || 0)
}; // 先手动指定当前中心点,也可以根据当前元素的left+width/2 的到x top+height/2 得到y值
// 计算出当前鼠标相对于元素中心点的坐标
x = x - origin.x; // 因为x大于origin.x 是在y轴右边,直接减就行了
y = origin.y - y; // 但是y如果要在x轴上方,它是比origin.y要小的,所以这里就需要反过来
// 然后计算就可以了
const deg = (Math.atan2(y, x) / Math.PI) * 180;
说明,旋转点的计算是根据浏览器的可视高度计算的,所以用
offsetTop+$dom.clientHeight(水印的一半)
计算的值要在减去滚轮的scrollY
才计算的精准
移动部分
移动部分代码逻辑
- 通过javascript存在的drag属性去控制
- 在
dragstart
的时候记录鼠标起始点,记录后开始监听drag时间,通过鼠标移动的距离-减去起始点+之前移动的距离
来计算计算 transformOption
为记录之前移动的距离和旋转度数,防止第二次拖动或旋转的时候,水印复位- 因为css3的旋转和移动都记录在一个属性上,没办法拆分,
移动和旋转之前,一定一定要把之前的状态带上
drag_handle(e) {
for (let i = 0; i < this.markList.length; i++) {
const $dom = this.$refs['ref-name'][i];
const transformOption = this.get_origin_transform($dom);
const startX = e.clientX;
const startY = e.clientY;
$dom.ondrag = (e) => {
e.preventDefault();
const option = {
moveX:
e.clientX -
startX +
parseInt(transformOption.translateX || 0),
moveY:
e.clientY -
startY +
parseInt(transformOption.translateY || 0),
deg: transformOption.rotate || 0
};
this.set_transform($dom, option);
};
$dom.ondragover = (e) => {
e.preventDefault();
};
}
},
get_origin_transform($dom) {
let old = $dom.style.transform;
const transformOption = {};
if (old) {
old = old.split(' ');
old.forEach((item) => {
transformOption[item.replace(/\((.*)\)/, '')] =
item.replace(/[^0-9-]/g, '');
});
}
return transformOption;
},
set_transform(dom, option) {
Methods.css(dom, {
transform: `rotate(${option.deg}deg) translateX(${option.moveX}px) translateY(${option.moveY}px)`
});
},
设置transform属性
moveX:
e.clientX -
startX +
parseInt(transformOption.translateX || 0),
moveY:
e.clientY -
startY +
parseInt(transformOption.translateY || 0),
deg: transformOption.rotate || 0
缩放部分
效果图:
实现的功能:
- 点击左上角的缩放图标,可进行拉伸
- 可以自定义个数
缩放部分代码逻辑
- 首先获取图标的第一个兄弟元素(img),通过
e.target.previousSibling
获取,以及图标的父元素(rotate-name) - 获取父元素的宽高,以及刚开始的鼠标坐标
- 算法:开始时的鼠标坐标 - 移动的鼠标坐标 + 父元素的宽高
限制了图片最小缩放为30*30
具体代码
scale_img(e) {
// 缩放图片
this._parentNode =
e.target.tagName.toLowerCase() === 'svg'
? e.target.previousSibling
: e.target.parentNode.previousSibling;
const _grentParentNode =
e.target.tagName.toLowerCase() === 'svg'
? e.target.parentNode
: e.target.parentNode.parentNode;
this._pos = {
w: _grentParentNode.offsetWidth,
h: _grentParentNode.offsetHeight,
x: e.clientX,
y: e.clientY
};
document.addEventListener('mousemove', this.get_scale);
document.addEventListener('mouseup', () => {
document.removeEventListener('mousemove', this.get_scale);
});
},
get_scale(e) {
// 缩放图片的mousemove事件,给图片设置宽高
e.preventDefault();
const $dom = this._parentNode;
// 设置图片的最小缩放为30*30
const w = Math.max(30, this._pos.x - e.clientX + this._pos.w - 20);
const h = Math.max(30, this._pos.y - e.clientY + this._pos.h);
$dom.style.width = w + 'px';
$dom.style.height = h + 'px';
},
颜色选择部分
效果图
实现的功能:
- 此功能单独抽了一个vue文件,可以在其他有需要的地方使用
- 可以通过内外两个部分,共同去决定颜色
- 颜色为双向绑定,视图可通过vm改变模型,模型也可通过vm改变视图
API
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
defaultColor | 默认的颜色 | String | #FFFFFF |
targetElem | 要绑定到哪个输入框上 | String | null |
Events API
事件名称 | 说明 | 回调参数 |
---|---|---|
onChange | 颜色改变的回调 | 绑定的颜色 |
tips
- 需要在输入框绑定focus事件,当focus时,执行组件的
openPicker
事件 - 当直接在输入框输入颜色时,要执行组件的
updateValue
方法,同步更新颜色选择器
使用方式
<color-picker
ref="ref-colorPicker"
:color="item.color"
:targetElem="'#color-input'+item.index"
@onChange="(color)=>{ set_form_color(color, item, index)}"
></color-picker>
set_form_color(color, item, index) {
item.color = color;
const $dom =
this.form.layout === '1'
? this.$refs['ref-img-container']
: this.$refs['ref-name'][index];
$dom.style.color = color;
},
具体的逻辑可以查看源码,实现起来挺复杂的,感谢万能的百度
接下来是核心部分
生成图片的部分
效果图
点击生成图片,便生成了canvas
使用方式
- 方法被单独抽出,可以实现自定义的代码转化成canvas
- 方法有两个参数,第一个参数为传入的dom元素,第二个参数为设置到canvas上的css属性
- 调用后,通过.then方法可以获取到生成图片的base64
具体的逻辑
- 大体逻辑为:将html代码读取,转化为svg,最后在转成canvas(也可以使用html2canvas)
- 定义一个str缓存,用于存放生成的dom。
- 根据传入的dom,遍历里面的所有元素。并且遍历所有元素的css属性,最后拼接起来
- 通过
'data:image/svg+xml;base64,' + window.btoa(unescape(encodeURIComponent(data)))
将svg字符串变成svg格式的base64 - 最后通过
ctx.drawImage(img, 0, 0);
绘制到canvas上
说明
1、最后绘制上去后,要remove掉传入的元素,并把canvas上树,替代原来的元素
2、在绘制svg的时候,对img以及svg,path标签进行了特殊处理,svg,path直接忽视跳过,img除了读取css属性,还要读取src属性,并且读取的src属性必须转化为base64格式
详细的使用方式和原理请看 如何将web代码变成canvas元素
导出图片
通过 <a :href="url" :download="markList[0].username+'.png'">导出</a>
导出,href为图片的base64地址,下载名称为,第一个水印的名称
第三部分 html2canvas的简单实现
实现的功能
- 将传入的html元素变成canvas
- 最后返回一个promise对象,对象为canvas元素
使用方式
参数1: 要转换的dom元素
参数2: 要设置给canvas的配置{ width: 100, height: 100, style: {}}
callback:promise对象(canvas元素)
Methods.htmlTocanvas($dom, option).then(($canvas) => {
// 监听元素
this.listen_dom($canvas);
this.listen_css($canvas);
});
具体的实现
- 先将传入的dom元素解析,转化成字符串格式,并并入到svg规格下,插入到
<foreignObject></foreignObject>
标签中
async function get_svg_dom_string(element) { // 将html代码嵌入svg
const $dom = await render_dom(element, true);
return `
<svg xmlns="http://www.w3.org/2000/svg" width = "${options.width}" height = "${options.height}">
<foreignObject width="100%" height="100%">
${$dom}
</foreignObject>\n
</svg>
`;
}
- 递归调用标签生成dom字符串
说明
- 对img标签特殊处理,因为img标签不仅要有style属性,还要有src属性,并且svg只支持base64格式的src
- 对svg以及path标签进行过滤,意思是,不允许dom字符串含有svg格式的icon
async function render_dom(element, isTop) { // 递归调用获取子标签
const tag = element.tagName.toLowerCase();
let str = `<${tag} `;
let flag = true;
// 最外层的节点要加xmlns命名空间
isTop && (str += `xmlns="http://www.w3.org/1999/xhtml" `);
if (str === '<img ') { // img标签特殊处理
flag = false;
let base64Img = '';
if (element.src.length > 30000) { // 判断src属性是不是base64, 是的话不用处理,不是的话,转换base64
base64Img = element.src;
} else {
base64Img = await getBase64Image(element.src);
}
str += `src="${base64Img}" style="${get_element_styles(element)}" />\n`;
} else if (str.includes('svg') || str.includes('path')) {
flag = false;
str = '';
} else {
str += `style="${get_element_styles(element)}">\n`;
}
if (element.children.length) {
for (const el of element.children) {
str += await render_dom(el);
}
} else {
str += element.innerHTML;
}
if (flag) {
str += `</${tag}>\n`;
}
return str;
}
3、遍历读取到的元素的css属性,并赋值到style上
function get_element_styles(element) { // 获取标签的所有样式
const css = window.getComputedStyle(element);
let style = '';
for (const key of css) {
if (key === '-webkit-locale') {
style += '';
} else {
style += `${key}:${css[key]};`;
}
}
return style;
}
4、最后声明一个new Image()
, 将svg转化成base64格式并赋值到img.src上,因为img.onload是一个异步的,所以返回一个promise对象
async function init_main() { // 主方法
const data = await get_svg_dom_string(dom);
// const DOMURL = window.URL || window.webkitURL || window;
const img = new Image();
img.src = 'data:image/svg+xml;base64,' + window.btoa(unescape(encodeURIComponent(data)));
// const svg = new Blob([data], {type: 'image/svg+xml;charset=utf-8'});
// const url = DOMURL.createObjectURL(svg);
// img.setAttribute('crossOrigin', 'anonymous');
// img.src = url;
return new Promise(resolve => {
img.onload = function() { // 最终生成的canvas
ctx.drawImage(img, 0, 0);
const parentNode = dom.parentNode;
parentNode.insertBefore($canvas, dom); // 将canvas插入原来的位置
parentNode.removeChild(dom); // 最终移除页面中被转换的代码
resolve($canvas);
};
});
}
开发时遇到的难点与解决方式
-
img标签附带src属性,需要特殊处理,并且src属性为base64,原因如下:
- 代码中的拼接方式只会拼接style属性,对于img这种特殊的标签需要特殊处理
- src指向的是本地的图片地址,转化为字符串后,读取不到,所以要转化为base64
-
使用将拼接的svg字符串变成blob格式,赋值给
new Image().src
属性的话,会造成跨域的问题,会导致$canvas.toDataURL()方法无法使用,所以要使用img.src = 'data:image/svg+xml;base64,' + window.btoa(unescape(encodeURIComponent(data)));
这种方式 -
部分情况下,对于css样式
-webkit-locale
要特殊处理,要将其忽视掉,原因如下:- 读取css样式并赋值到style上的时候,
-webkit-locale
属性会将字符串中断,导致后面的样式读取不到
- 读取css样式并赋值到style上的时候,
遗留的问题
- 对特殊标签无法处理(带其他属性的标签),需要在方法中进行拓展
- 通过
window.getComputedStyle(element);
获取的css属性太多,加上base64,拼接后会导致字符串很长