1、效果展示
演示地址:little-littleprogrammer.github.io/npm-compone…
(地址里还有其他小组件,有兴趣的可以看下)
2、实现的功能
- 水印样式可自定义 -- 斜向平铺以及自定义
- 自定义样式可自定义水印的个数,颜色,大小,可以实现水印旋转,拖拽
- 可导出为png格式的图片,文件名为第一个水印的名称
- 可添加图片水印
3、具体实现
说明: 1、本文通过循序渐进的方式讲解,所以重复代码可能有点多。 2、本文尽可能针对难点性问题讲解,最终所有代码会呈现在底部github链接中
选择图片部分
<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>
<script>
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');
};
},
</script>
1、选择文件后,通过get_file_data方法,将图片转化为base64
2、根据图片的大小,去控制result-container和img-container容器的大小,当超过浏览器宽度时,限制图片为浏览器宽度
水印样式部分
layout控制样式,1是斜向平铺,2是自定义样式(自定义样式可添加文字水印和图片水印)
1、斜向平铺
最终效果:
<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;
}
}
斜向平铺样式主要为css控制,我这里使用的笨办法,设置很多行,一行很多个,对不同的行设置不同的旋转点,旋转。最后溢出的部分overflow:hidden
掉
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)`
});
},
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改变视图
具体的逻辑可以查看源码,实现起来挺复杂的,感谢万能的百度
接下来是核心部分
生成图片的部分
效果图
点击生成图片,便生成了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格式
详细的使用方式和原理请看 html2canvas的简单实现
具体代码
/**
* 将html代码转化为图片
* @param {*} dom dom元素
* @param {*} options 配置 宽高:width, height, canvas样式:style
* @description
*/
methods.htmlTocanvas = (dom, options) => {
options = Object.assign({ width: 100, height: 100, style: {}}, options); // 默认样式
const $canvas = document.createElement('canvas');
$canvas.id = 'canvas';
$canvas.width = options.width;
$canvas.height = options.height;
if (JSON.stringify(options.style) !== '{}') { // 配置canvas的样式
for (const key in options.style) {
methods.css($canvas, `${key}`, `${options.style[key]}`);
}
}
const ctx = $canvas.getContext('2d');
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.toDataURL('image/png'));
};
});
}
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>
`;
}
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;
}
function get_element_styles(element) { // 获取标签的所有样式
const css = window.getComputedStyle(element);
let style = '';
for (const key of css) {
style += `${key}:${css[key]};`;
}
return style;
}
function getBase64Image(img) { // 获取图片的base64
const image = new Image();
image.src = img;
return new Promise(resolve => {
image.onload = function(){
const canvas = document.createElement('canvas');
canvas.id = 'image';
canvas.width = image.width;
canvas.height = image.height;
const ctxImg = canvas.getContext('2d');
ctxImg.drawImage(image, 0, 0, image.width, image.height);
const ext = image.src.substring(image.src.lastIndexOf('.') + 1).toLowerCase();
const dataURL = canvas.toDataURL('image/' + ext);
resolve(dataURL);
};
});
}
return init_main();
};
导出图片
通过 <a :href="url" :download="markList[0].username+'.png'">导出</a>
导出,href为图片的base64地址,下载名称为,第一个水印的名称
GitHub地址:github.com/Little-Litt…