<template> <div class="vue-image-crop-upload" v-show="modelValue"> <div class="vicp-wrap"> <div class="vicp-close" @click="off"> <i class="vicp-icon4"></i> </div> <div class="vicp-step1" v-show="step == 1"> <div class="vicp-drop-area" @dragleave="preventDefault" @dragover="preventDefault" @dragenter="preventDefault" @click="handleClick" @drop="handleChange"> <i class="vicp-icon1" v-show="loading != 1"> <i class="vicp-icon1-arrow"></i> <i class="vicp-icon1-body"></i> <i class="vicp-icon1-bottom"></i> </i> <span class="vicp-hint" v-show="loading !== 1">{{ lang.hint }}</span> <span class="vicp-no-supported-hint" v-show="!isSupported">{{ lang.noSupported }}</span> <input type="file" accept="image/*" v-show="false" v-if="step == 1" @change="handleChange" ref="fileinput"> </div> <div class="vicp-error" v-show="hasError"> <i class="vicp-icon2"></i> {{ errorMsg }} </div> <div class="vicp-operate"> <a @click="off" @mousedown="ripple">{{ lang.btn.off }}</a> </div> </div> <div class="vicp-step2" v-if="step == 2"> <div class="vicp-crop"> <div class="vicp-crop-left" v-show="true"> <div class="vicp-img-container" @wheel.prevent="handleMouseWheel"> <img :src="sourceImgUrl" :style="sourceImgStyle" class="vicp-img" draggable="false" @drag="preventDefault" @dragstart="preventDefault" @dragend="preventDefault" @dragleave="preventDefault" @dragover="preventDefault" @dragenter="preventDefault" @drop="preventDefault" @touchstart="imgStartMove" @touchmove="imgMove" @touchend="createImg" @touchcancel="createImg" @mousedown="imgStartMove" @mousemove="imgMove" @mouseup="createImg" @mouseout="createImg" ref="img"> <div class="vicp-img-shade vicp-img-shade-1" :style="sourceImgShadeStyle"></div> <div class="vicp-img-shade vicp-img-shade-2" :style="sourceImgShadeStyle"></div> </div> <div class="vicp-range"> <input type="range" v-model="scale.range" step="1" min="0" max="100" @mousemove="zoomChange"> <i @mousedown="startZoomSub" @mouseout="endZoomSub" @mouseup="endZoomSub" class="vicp-icon5"></i> <i @mousedown="startZoomAdd" @mouseout="endZoomAdd" @mouseup="endZoomAdd" class="vicp-icon6"></i> </div> <div class="vicp-rotate" v-if="!noRotate"> <i @click="rotateImg">↻</i> </div> </div> <div class="vicp-crop-right" v-show="true"> <div class="vicp-preview"> <div class="vicp-preview-item" v-if="!noSquare"> <img :src="createImgUrl" :style="previewStyle"> <span>{{ lang.preview }}</span> </div> <div class="vicp-preview-item vicp-preview-item-circle" v-if="!noCircle"> <img :src="createImgUrl" :style="previewStyle"> <span>{{ lang.preview }}</span> </div> </div> </div> </div> <div class="vicp-operate"> <a @click="setStep(1)" @mousedown="ripple">{{ lang.btn.back }}</a> <a class="vicp-operate-btn" @click="prepareUpload" @mousedown="ripple">{{ lang.btn.save }}</a> </div> </div> <div class="vicp-step3" v-if="step == 3"> <div class="vicp-upload"> <span class="vicp-loading" v-show="loading === 1">{{ lang.loading }}</span> <div class="vicp-progress-wrap"> <span class="vicp-progress" v-show="loading === 1" :style="progressStyle"></span> </div> <div class="vicp-error" v-show="hasError"> <i class="vicp-icon2"></i> {{ errorMsg }} </div> <div class="vicp-success" v-show="loading === 2"> <i class="vicp-icon3"></i> {{ lang.success }} </div> </div> <div class="vicp-operate"> <a @click="setStep(2)" @mousedown="ripple">{{ lang.btn.back }}</a> <a @click="off" @mousedown="ripple">{{ lang.btn.close }}</a> </div> </div> <canvas v-show="false" :width="width" :height="height" ref="canvas"></canvas> </div> </div> </template> <script> 'use strict'; import language from './utils/language.js'; import mimes from './utils/mimes.js'; import data2blob from './utils/data2blob.js'; import effectRipple from './utils/effectRipple.js'; export default { props: { // 域,上传文件name,触发事件会带上(如果一个页面多个图片上传控件,可以做区分 field: { type: String, 'default': 'avatar' }, // 原名key,类似于id,触发事件会带上(如果一个页面多个图片上传控件,可以做区分 ki: { type: String, 'default': '0' }, // 显示该控件与否 modelValue: { type: Boolean, 'default': true }, // 上传地址 url: { type: String, 'default': '' }, // 其他要上传文件附带的数据,对象格式 params: { type: Object, 'default': ()=>null }, //Add custom headers headers: { type: Object, 'default': ()=>null }, // 剪裁图片的宽 width: { type: Number, default: 200 }, // 剪裁图片的高 height: { type: Number, default: 200 }, // 不显示旋转功能 noRotate: { type: Boolean, default: true }, // 不预览圆形图片 noCircle: { type: Boolean, default: false }, // 不预览方形图片 noSquare: { type: Boolean, default: false }, // 单文件大小限制 maxSize: { type: Number, 'default': 10240 }, // 语言类型 langType: { type: String, 'default': 'zh' }, // 语言包 langExt: { type: Object, 'default': ()=>null }, // 图片上传格式 imgFormat: { type: String, 'default': 'png' }, // 图片背景 jpg情况下生效 imgBgc: { type: String, 'default': '#fff' }, // 是否支持跨域 withCredentials: { type: Boolean, 'default': false }, method: { type: String, 'default': 'POST' }, initialImgUrl: { type: String, 'default': '' }, allowImgFormat: { type: Array, 'default': ()=>[ 'gif', 'jpg', 'png' ] } }, data() { let that = this, { imgFormat, allowImgFormat, langType, langExt, width, height } = that, isSupported = true, tempImgFormat = allowImgFormat.indexOf(imgFormat) === -1 ? 'jpg' : imgFormat, lang = language[langType] ? language[langType] : language['en'], mime = mimes[tempImgFormat]; // 规范图片格式 // that.imgFormat = tempImgFormat; if (langExt) { Object.assign(lang, langExt); } if (typeof FormData != 'function') { isSupported = false; } return { // 图片的mime mime, // 语言包 lang, // 浏览器是否支持该控件 isSupported, // 浏览器是否支持触屏事件 isSupportTouch: document.hasOwnProperty("ontouchstart"), // 步骤 step: 1, //1选择文件 2剪裁 3上传 // 上传状态及进度 loading: 0, //0未开始 1正在 2成功 3错误 progress: 0, // 是否有错误及错误信息 hasError: false, errorMsg: '', // 需求图宽高比 ratio: width / height, // 原图地址、生成图片地址 sourceImg: null, sourceImgUrl: this.initialImgUrl, createImgUrl: this.initialImgUrl, // 原图片拖动事件初始值 sourceImgMouseDown: { on: false, mX: 0, //鼠标按下的坐标 mY: 0, x: 0, //scale原图坐标 y: 0 }, // 生成图片预览的容器大小 previewContainer: { width: 100, height: 100 }, // 原图容器宽高 sourceImgContainer: { // sic width: 240, height: 184 // 如果生成图比例与此一致会出现bug,先改成特殊的格式吧,哈哈哈 }, // 原图展示属性 scale: { zoomAddOn: false, //按钮缩放事件开启 zoomSubOn: false, //按钮缩放事件开启 range: 1, //最大100 x: 0, y: 0, width: 0, height: 0, maxWidth: 0, maxHeight: 0, minWidth: 0, //最宽 minHeight: 0, naturalWidth: 0, //原宽 naturalHeight: 0 } } }, computed: { // 进度条样式 progressStyle() { let { progress } = this; return { width: progress + '%' } }, // 原图样式 sourceImgStyle() { let { scale, sourceImgMasking } = this, top = scale.y + sourceImgMasking.y + 'px', left = scale.x + sourceImgMasking.x + 'px'; return { top, left, width: scale.width + 'px', height: scale.height + 'px',// 兼容 Opera } }, // 原图蒙版属性 sourceImgMasking() { let { width, height, ratio, sourceImgContainer } = this, sic = sourceImgContainer, sicRatio = sic.width / sic.height, // 原图容器宽高比 x = 0, y = 0, w = sic.width, h = sic.height, scale = 1; if (ratio < sicRatio) { scale = sic.height / height; w = sic.height * ratio; x = (sic.width - w) / 2; } if (ratio > sicRatio) { scale = sic.width / width; h = sic.width / ratio; y = (sic.height - h) / 2; } return { scale, // 蒙版相对需求宽高的缩放 x, y, width: w, height: h }; }, // 原图遮罩样式 sourceImgShadeStyle() { let { sourceImgMasking, sourceImgContainer } = this, sic = sourceImgContainer, sim = sourceImgMasking, w = sim.width == sic.width ? sim.width : (sic.width - sim.width) / 2, h = sim.height == sic.height ? sim.height : (sic.height - sim.height) / 2; return { width: w + 'px', height: h + 'px' }; }, previewStyle() { let { width, height, ratio, previewContainer } = this, pc = previewContainer, w = pc.width, h = pc.height, pcRatio = w / h; if (ratio < pcRatio) { w = pc.height * ratio; } if (ratio > pcRatio) { h = pc.width / ratio; } return { width: w + 'px', height: h + 'px' }; } }, watch: { modelValue(newValue) { if (newValue && this.loading != 1) { this.reset(); } } }, created(){ // 绑定按键esc隐藏此插件事件 document.addEventListener('keyup', this.handleEscClose ) }, beforeUnmount(){ document.removeEventListener('keyup', this.handleEscClose ) }, mounted() { if (this.sourceImgUrl) { this.startCrop(); } }, methods: { handleEscClose(e){ if(this.modelValue && (e.key == 'Escape' || e.keyCode == 27)){ this.off(); } }, // 点击波纹效果 ripple(e) { effectRipple(e); }, // 关闭控件 off() { setTimeout(()=> { this.$emit('update:modelValue', false); if(this.step == 3 && this.loading == 2){ this.setStep(1); } }, 200); }, // 设置步骤 setStep(no) { // 延时是为了显示动画效果呢,哈哈哈 setTimeout(()=> { this.step = no; }, 200); }, /* 图片选择区域函数绑定 ---------------------------------------------------------------*/ preventDefault(e) { e.preventDefault(); return false; }, handleClick(e) { if (this.loading !== 1) { if (e.target !== this.$refs.fileinput) { e.preventDefault(); if (document.activeElement !== this.$refs) { this.$refs.fileinput.click(); } } } }, handleChange(e) { e.preventDefault(); if (this.loading !== 1) { let files = e.target.files || e.dataTransfer.files; this.reset(); if (this.checkFile(files[0])) { this.setSourceImg(files[0]); } } }, /* ---------------------------------------------------------------*/ // 检测选择的文件是否合适 checkFile(file) { let that = this, { lang, maxSize } = that; // 仅限图片 if (file.type.indexOf('image') === -1) { that.hasError = true; that.errorMsg = lang.error.onlyImg; return false; } // 超出大小 if (file.size / 1024 > maxSize) { that.hasError = true; that.errorMsg = lang.error.outOfSize + maxSize + 'kb'; return false; } return true; }, // 重置控件 reset() { let that = this; that.loading = 0; that.hasError = false; that.errorMsg = ''; that.progress = 0; }, // 设置图片源 setSourceImg(file) { this.$emit('src-file-set', file.name, file.type, file.size); let that = this, fr = new FileReader(); fr.onload = function(e) { that.sourceImgUrl = fr.result; that.startCrop(); } fr.readAsDataURL(file); }, // 剪裁前准备工作 startCrop() { let that = this, { width, height, ratio, scale, sourceImgUrl, sourceImgMasking, lang } = that, sim = sourceImgMasking, img = new Image(); img.src = sourceImgUrl; img.onload = function() { let nWidth = img.naturalWidth, nHeight = img.naturalHeight, nRatio = nWidth / nHeight, w = sim.width, h = sim.height, x = 0, y = 0; // 图片像素不达标 if (nWidth < width || nHeight < height) { that.hasError = true; that.errorMsg = lang.error.lowestPx + width + '*' + height; return false; } if (ratio > nRatio) { h = w / nRatio; y = (sim.height - h) / 2; } if (ratio < nRatio) { w = h * nRatio; x = (sim.width - w) / 2; } scale.range = 0; scale.x = x; scale.y = y; scale.width = w; scale.height = h; scale.minWidth = w; scale.minHeight = h; scale.maxWidth = nWidth * sim.scale; scale.maxHeight = nHeight * sim.scale; scale.naturalWidth = nWidth; scale.naturalHeight = nHeight; that.sourceImg = img; that.createImg(); that.setStep(2); }; }, // 鼠标按下图片准备移动 imgStartMove(e) { e.preventDefault(); // 支持触摸事件,则鼠标事件无效 if(this.isSupportTouch && !e.targetTouches){ return false; } let et = e.targetTouches ? e.targetTouches[0] : e, { sourceImgMouseDown, scale } = this, simd = sourceImgMouseDown; simd.mX = et.screenX; simd.mY = et.screenY; simd.x = scale.x; simd.y = scale.y; simd.on = true; }, // 鼠标按下状态下移动,图片移动 imgMove(e) { e.preventDefault(); // 支持触摸事件,则鼠标事件无效 if(this.isSupportTouch && !e.targetTouches){ return false; } let et = e.targetTouches ? e.targetTouches[0] : e, { sourceImgMouseDown: { on, mX, mY, x, y }, scale, sourceImgMasking } = this, sim = sourceImgMasking, nX = et.screenX, nY = et.screenY, dX = nX - mX, dY = nY - mY, rX = x + dX, rY = y + dY; if (!on) return; if (rX > 0) { rX = 0; } if (rY > 0) { rY = 0; } if (rX < sim.width - scale.width) { rX = sim.width - scale.width; } if (rY < sim.height - scale.height) { rY = sim.height - scale.height; } scale.x = rX; scale.y = rY; }, // 顺时针旋转图片 rotateImg(e) { let { sourceImg, scale: { naturalWidth, naturalHeight, } } = this, width = naturalHeight, height = naturalWidth, canvas = this.$refs.canvas, ctx = canvas.getContext('2d'); canvas.width = width; canvas.height = height; ctx.clearRect(0, 0, width, height); ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.fillRect(0, 0, width, height); ctx.translate(width, 0); ctx.rotate(Math.PI * 90 / 180); ctx.drawImage(sourceImg, 0, 0, naturalWidth, naturalHeight); let imgUrl = canvas.toDataURL(mimes['png']); this.sourceImgUrl = imgUrl; this.startCrop(); }, handleMouseWheel(e){ e = e || window.event; let { scale } = this; if (e.wheelDelta) { //判断浏览器IE,谷歌滑轮事件 if (e.wheelDelta > 0) { //当滑轮向上滚动时 this.zoomImg(scale.range >= 100 ? 100 : ++scale.range); } if (e.wheelDelta < 0) { this.zoomImg(scale.range <= 0 ? 0 : --scale.range); } } else if (e.detail) { //Firefox滑轮事件 if (e.detail > 0) { //当滑轮向上滚动时 this.zoomImg(scale.range >= 100 ? 100 : ++scale.range); } if (e.detail < 0) { this.zoomImg(scale.range <= 0 ? 0 : --scale.range); } } }, // 按钮按下开始放大 startZoomAdd(e) { let that = this, { scale } = that; scale.zoomAddOn = true; function zoom() { if (scale.zoomAddOn) { let range = scale.range >= 100 ? 100 : ++scale.range; that.zoomImg(range); setTimeout(function() { zoom(); }, 60); } } zoom(); }, // 按钮松开或移开取消放大 endZoomAdd(e) { this.scale.zoomAddOn = false; }, // 按钮按下开始缩小 startZoomSub(e) { let that = this, { scale } = that; scale.zoomSubOn = true; function zoom() { if (scale.zoomSubOn) { let range = scale.range <= 0 ? 0 : --scale.range; that.zoomImg(range); setTimeout(function() { zoom(); }, 60); } } zoom(); }, // 按钮松开或移开取消缩小 endZoomSub(e) { let { scale } = this; scale.zoomSubOn = false; }, zoomChange(e) { this.zoomImg(e.target.value); }, // 缩放原图 zoomImg(newRange) { let that = this, { sourceImgMasking, sourceImgMouseDown, scale } = this, { maxWidth, maxHeight, minWidth, minHeight, width, height, x, y, range } = scale, sim = sourceImgMasking, // 蒙版宽高 sWidth = sim.width, sHeight = sim.height, // 新宽高 nWidth = minWidth + (maxWidth - minWidth) * newRange / 100, nHeight = minHeight + (maxHeight - minHeight) * newRange / 100, // 新坐标(根据蒙版中心点缩放) nX = sWidth / 2 - (nWidth / width) * (sWidth / 2 - x), nY = sHeight / 2 - (nHeight / height) * (sHeight / 2 - y); // 判断新坐标是否超过蒙版限制 if (nX > 0) { nX = 0; } if (nY > 0) { nY = 0; } if (nX < sWidth - nWidth) { nX = sWidth - nWidth; } if (nY < sHeight - nHeight) { nY = sHeight - nHeight; } // 赋值处理 scale.x = nX; scale.y = nY; scale.width = nWidth; scale.height = nHeight; scale.range = newRange; setTimeout(function() { if (scale.range == newRange) { that.createImg(); } }, 300); }, // 生成需求图片 createImg(e) { let that = this, { imgFormat, imgBgc, mime, sourceImg, scale: { x, y, width, height, }, sourceImgMasking: { scale } } = that, canvas = that.$refs.canvas, ctx = canvas.getContext('2d'); if (e) { // 取消鼠标按下移动状态 that.sourceImgMouseDown.on = false; } canvas.width = that.width; canvas.height = that.height; ctx.clearRect(0, 0, that.width, that.height); if(imgFormat == 'png'){ ctx.fillStyle = 'rgba(0,0,0,0)'; } else{ // 如果jpg 为透明区域设置背景,默认白色 ctx.fillStyle = imgBgc; } ctx.fillRect(0, 0, that.width, that.height); ctx.drawImage(sourceImg, x / scale, y / scale, width / scale, height / scale); that.createImgUrl = canvas.toDataURL(mime); }, prepareUpload(){ let { url, createImgUrl, field, ki } = this; this.$emit('crop-success', createImgUrl, field, ki); if(typeof url == 'string' && url){ this.upload(); }else{ this.off(); } }, // 上传图片 upload() { let that = this, { lang, imgFormat, mime, url, params, headers, field, ki, createImgUrl, withCredentials, method } = this, fmData = new FormData(); // 添加其他参数 if (typeof params == 'object' && params) { Object.keys(params).forEach((k) => { fmData.append(k, params[k]); }) } // 将field的添加放到表单域的最后,以支持阿里云OSS的表单上传 fmData.append(field, data2blob(createImgUrl, mime), field + '.' + imgFormat); // 监听进度回调 const uploadProgress = function(event) { if (event.lengthComputable) { that.progress = 100 * Math.round(event.loaded) / event.total; } }; // 上传文件 that.reset(); that.loading = 1; that.setStep(3); new Promise(function(resolve, reject) { let client = new XMLHttpRequest(); client.open(method, url, true); client.withCredentials = withCredentials; client.onreadystatechange = function() { if (this.readyState !== 4) { return; } if (this.status === 200 || this.status === 201 || this.staus ===202 ) { resolve(JSON.parse(this.responseText)); } else { reject(this.status); } }; client.upload.addEventListener("progress", uploadProgress, false); //监听进度 // 设置header if (typeof headers == 'object' && headers) { Object.keys(headers).forEach((k) => { client.setRequestHeader(k, headers[k]); }) } client.send(fmData); }).then( // 上传成功 function(resData) { if (that.modelValue) { that.loading = 2; that.$emit('crop-upload-success', resData, field, ki); } }, // 上传失败 function(sts) { if (that.modelValue) { that.loading = 3; that.hasError = true; that.errorMsg = lang.fail; that.$emit('crop-upload-fail', sts, field, ki); } } ); } }, } </script> <!-- <style lang='sass' src="./scss/upload.scss"> </style> --> <style> @import url('upload.css'); </style>