cropper.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. /**
  2. * 图片裁剪核心库 - View显示版本
  3. * 使用view和image显示界面,只在导出时使用canvas
  4. * 支持H5、App、微信小程序、支付宝小程序、抖音小程序
  5. */
  6. export default class ImageCropper {
  7. constructor(context,options) {
  8. this.context = context;
  9. this.options = options || {};
  10. this.canvasId = options.canvasId;
  11. this.fileType = options.fileType || 'jpg';
  12. this.quality = options.quality || 0.8;
  13. const { windowWidth, windowHeight } = uni.$u.window();
  14. // 画布尺寸(显示区域)
  15. this.canvasWidth = options.canvasWidth || windowWidth;
  16. this.canvasHeight = options.canvasHeight || windowHeight;
  17. // 输出图片配置
  18. this.width = options.width || 400;
  19. this.height = options.height || 400;
  20. // 裁剪框配置
  21. this.rectWidth = options.rectWidth || 400;
  22. this.rectHeight = options.rectHeight || 400;
  23. // 图片数据
  24. this.imgData = {
  25. x: 0,
  26. y: 0,
  27. width: 0,
  28. height: 0,
  29. scale: 1,
  30. angle: 0,
  31. originalWidth: 0,
  32. originalHeight: 0,
  33. src: ''
  34. };
  35. // 触摸数据
  36. this.touch = {
  37. startX: 0,
  38. startY: 0,
  39. isTouch: false,
  40. isMove: false
  41. };
  42. // 图片加载状态
  43. this.imageLoaded = false;
  44. // 回调函数
  45. this.onUpdate = options.onUpdate || (() => {});
  46. this.triggerUpdate();
  47. }
  48. // 设置图片源
  49. setImage(src) {
  50. return new Promise((resolve, reject) => {
  51. if (!src) {
  52. reject(new Error('图片路径不能为空'));
  53. return;
  54. }
  55. uni.getImageInfo({
  56. src: src,
  57. success: (res) => {
  58. this.imgData.src = src;
  59. this.imgData.originalWidth = res.width;
  60. this.imgData.originalHeight = res.height;
  61. this.imageLoaded = true;
  62. this.resetImageData();
  63. this.triggerUpdate();
  64. resolve(res);
  65. },
  66. fail: () => reject(new Error('图片信息获取失败'))
  67. });
  68. });
  69. }
  70. // 重置图片数据
  71. resetImageData() {
  72. if (!this.imageLoaded) return;
  73. const imgWidth = this.imgData.originalWidth;
  74. const imgHeight = this.imgData.originalHeight;
  75. // 计算让图片宽度或高度等于裁剪框尺寸的缩放比例
  76. const scaleX = this.rectWidth / imgWidth;
  77. const scaleY = this.rectHeight / imgHeight;
  78. // 使用较大的缩放比例,确保图片至少有一边等于裁剪框尺寸
  79. const scale = Math.max(scaleX, scaleY);
  80. // 计算裁剪框在画布中的居中位置
  81. const rectX = (this.canvasWidth - this.rectWidth) / 2;
  82. const rectY = (this.canvasHeight - this.rectHeight) / 2;
  83. // 保持当前的旋转角度
  84. const currentAngle = this.imgData.angle || 0;
  85. this.imgData = {
  86. ...this.imgData,
  87. x: rectX + (this.rectWidth - imgWidth * scale) / 2,
  88. y: rectY + (this.rectHeight - imgHeight * scale) / 2,
  89. width: imgWidth * scale,
  90. height: imgHeight * scale,
  91. scale: scale,
  92. angle: currentAngle
  93. };
  94. // 如果图片有旋转,需要重新计算边界限制
  95. if (currentAngle !== 0) {
  96. this.applyBoundaryConstraints();
  97. }
  98. }
  99. // 应用边界约束
  100. applyBoundaryConstraints() {
  101. // 计算裁剪框在画布中的居中位置
  102. const rectX = (this.canvasWidth - this.rectWidth) / 2;
  103. const rectY = (this.canvasHeight - this.rectHeight) / 2;
  104. // 获取旋转后的边界框
  105. const rotatedBounds = this.getRotatedBounds();
  106. // 计算图片中心点相对于裁剪框的偏移范围
  107. const maxOffsetX = Math.max(0, (rotatedBounds.width - this.rectWidth) / 2);
  108. const maxOffsetY = Math.max(0, (rotatedBounds.height - this.rectHeight) / 2);
  109. // 裁剪框中心点
  110. const rectCenterX = rectX + this.rectWidth / 2;
  111. const rectCenterY = rectY + this.rectHeight / 2;
  112. // 当前图片中心点
  113. const imgCenterX = this.imgData.x + this.imgData.width / 2;
  114. const imgCenterY = this.imgData.y + this.imgData.height / 2;
  115. // 限制图片中心点的移动范围
  116. const limitedCenterX = Math.max(rectCenterX - maxOffsetX, Math.min(rectCenterX + maxOffsetX, imgCenterX));
  117. const limitedCenterY = Math.max(rectCenterY - maxOffsetY, Math.min(rectCenterY + maxOffsetY, imgCenterY));
  118. // 重新计算图片位置
  119. this.imgData.x = limitedCenterX - this.imgData.width / 2;
  120. this.imgData.y = limitedCenterY - this.imgData.height / 2;
  121. }
  122. // 计算旋转后图片的边界框
  123. getRotatedBounds() {
  124. const { width, height, angle } = this.imgData;
  125. if (angle === 0 || angle % 360 === 0) {
  126. return { width, height };
  127. }
  128. // 将角度转换为弧度
  129. const rad = (angle * Math.PI) / 180;
  130. // 计算旋转后的边界框
  131. const cos = Math.abs(Math.cos(rad));
  132. const sin = Math.abs(Math.sin(rad));
  133. const rotatedWidth = width * cos + height * sin;
  134. const rotatedHeight = width * sin + height * cos;
  135. return {
  136. width: rotatedWidth,
  137. height: rotatedHeight
  138. };
  139. }
  140. // 触摸开始
  141. touchStart(e) {
  142. if (!this.imageLoaded) return;
  143. const x = e.touches ? e.touches[0].clientX : e.clientX;
  144. const y = e.touches ? e.touches[0].clientY : e.clientY;
  145. this.touch.startX = x;
  146. this.touch.startY = y;
  147. this.touch.isTouch = true;
  148. this.touch.isMove = false;
  149. }
  150. // 触摸移动
  151. touchMove(e) {
  152. if (!this.touch.isTouch || !this.imageLoaded) return;
  153. e.preventDefault && e.preventDefault();
  154. const x = e.touches ? e.touches[0].clientX : e.clientX;
  155. const y = e.touches ? e.touches[0].clientY : e.clientY;
  156. // 单点拖拽
  157. const deltaX = x - this.touch.startX;
  158. const deltaY = y - this.touch.startY;
  159. // 计算新的图片位置
  160. let newX = this.imgData.x + deltaX;
  161. let newY = this.imgData.y + deltaY;
  162. // 计算裁剪框在画布中的居中位置
  163. const rectX = (this.canvasWidth - this.rectWidth) / 2;
  164. const rectY = (this.canvasHeight - this.rectHeight) / 2;
  165. // 获取旋转后的边界框
  166. const rotatedBounds = this.getRotatedBounds();
  167. // 计算图片中心点相对于裁剪框的偏移范围
  168. const maxOffsetX = Math.max(0, (rotatedBounds.width - this.rectWidth) / 2);
  169. const maxOffsetY = Math.max(0, (rotatedBounds.height - this.rectHeight) / 2);
  170. // 裁剪框中心点
  171. const rectCenterX = rectX + this.rectWidth / 2;
  172. const rectCenterY = rectY + this.rectHeight / 2;
  173. // 限制图片中心点的移动范围
  174. const imgCenterX = newX + this.imgData.width / 2;
  175. const imgCenterY = newY + this.imgData.height / 2;
  176. const limitedCenterX = Math.max(rectCenterX - maxOffsetX, Math.min(rectCenterX + maxOffsetX, imgCenterX));
  177. const limitedCenterY = Math.max(rectCenterY - maxOffsetY, Math.min(rectCenterY + maxOffsetY, imgCenterY));
  178. // 重新计算图片位置
  179. newX = limitedCenterX - this.imgData.width / 2;
  180. newY = limitedCenterY - this.imgData.height / 2;
  181. this.imgData.x = newX;
  182. this.imgData.y = newY;
  183. this.touch.startX = x;
  184. this.touch.startY = y;
  185. this.touch.isMove = true;
  186. this.triggerUpdate();
  187. }
  188. // 触摸结束
  189. touchEnd(e) {
  190. this.touch.isTouch = false;
  191. this.touch.isMove = false;
  192. }
  193. // 缩放图片
  194. scaleImage(ratio) {
  195. if (!this.imageLoaded) return;
  196. const newScale = this.imgData.scale * ratio;
  197. // 计算新的尺寸
  198. const newWidth = this.imgData.originalWidth * newScale;
  199. const newHeight = this.imgData.originalHeight * newScale;
  200. // 限制最小缩放,确保图片至少有一边等于或大于裁剪框
  201. const minScaleX = this.rectWidth / this.imgData.originalWidth;
  202. const minScaleY = this.rectHeight / this.imgData.originalHeight;
  203. const minScale = Math.max(minScaleX, minScaleY);
  204. // 限制缩放范围
  205. if (newScale < minScale || newScale > 3) return;
  206. // 保存当前中心点
  207. const centerX = this.imgData.x + this.imgData.width / 2;
  208. const centerY = this.imgData.y + this.imgData.height / 2;
  209. // 更新图片数据
  210. this.imgData.scale = newScale;
  211. this.imgData.width = newWidth;
  212. this.imgData.height = newHeight;
  213. // 重新计算位置,保持中心点不变
  214. this.imgData.x = centerX - this.imgData.width / 2;
  215. this.imgData.y = centerY - this.imgData.height / 2;
  216. // 应用边界约束
  217. this.applyBoundaryConstraints();
  218. this.triggerUpdate();
  219. }
  220. // 旋转图片
  221. rotate(angle = 90) {
  222. if (!this.imageLoaded) return;
  223. this.imgData.angle += angle;
  224. this.imgData.angle = this.imgData.angle % 360;
  225. // 旋转后应用边界约束
  226. this.applyBoundaryConstraints();
  227. this.triggerUpdate();
  228. }
  229. // 重置图片
  230. reset() {
  231. this.resetImageData();
  232. this.triggerUpdate();
  233. }
  234. loadImage(canvas,src) {
  235. return new Promise( (resolve, reject) => {
  236. if (this.options.type == '2d') {
  237. var img = canvas.createImage();
  238. img.onload = () => {
  239. resolve(img);
  240. };
  241. img.onerror = (e) => {
  242. reject(e);
  243. };
  244. img.src = src;
  245. } else {
  246. resolve(src);
  247. }
  248. })
  249. }
  250. canvasToTempFilePath(resolve, reject, canvas) {
  251. let params = {
  252. canvas: canvas,
  253. canvasId: this.canvasId,
  254. fileType: this.fileType,
  255. quality: this.quality,
  256. width: this.width,
  257. height: this.height,
  258. success: (res) => {
  259. resolve(res.tempFilePath)
  260. },
  261. fail: (err) => {
  262. console.error('导出图片失败:', err);
  263. reject(err);
  264. }
  265. };
  266. // #ifdef MP-ALIPAY
  267. uni.canvasToTempFilePath(params);
  268. // #endif
  269. // #ifndef MP-ALIPAY
  270. uni.canvasToTempFilePath(params, this.context);
  271. // #endif
  272. }
  273. // 导出裁剪图片
  274. getCropperImage() {
  275. return new Promise(async (resolve, reject) => {
  276. if (!this.imageLoaded) {
  277. reject(new Error('图片未加载'));
  278. return;
  279. }
  280. // 计算裁剪框在画布中的居中位置
  281. const rectX = (this.canvasWidth - this.rectWidth) / 2;
  282. const rectY = (this.canvasHeight - this.rectHeight) / 2;
  283. // 计算图片在裁剪框中的相对位置
  284. const imgX = this.imgData.x - rectX;
  285. const imgY = this.imgData.y - rectY;
  286. // 计算缩放比例 - 导出图片尺寸 vs 裁剪框尺寸
  287. const scaleX = this.width / this.rectWidth;
  288. const scaleY = this.height / this.rectHeight;
  289. let canvas = null;
  290. // #ifdef MP-ALIPAY
  291. let ctx = uni.createCanvasContext(this.canvasId);
  292. // #endif
  293. // #ifndef MP-ALIPAY
  294. let ctx = uni.createCanvasContext(this.canvasId, this.context);
  295. // #endif
  296. if(this.options.type == '2d'){
  297. canvas = await new Promise(resolve => {
  298. uni.createSelectorQuery()
  299. .in(this.context)
  300. .select(`#${this.canvasId}`)
  301. .fields({
  302. node: true,
  303. size: true
  304. })
  305. .exec(res => {
  306. resolve(res[0].node);
  307. });
  308. });
  309. canvas.width = this.width;
  310. canvas.height = this.height;
  311. ctx = canvas.getContext('2d');
  312. ctx.fillStyle = '#FFFFFF';
  313. ctx.fillRect(0, 0, this.width, this.height);
  314. }else{
  315. // 清空画布
  316. ctx.clearRect(0, 0, this.width, this.height);
  317. }
  318. const image = await this.loadImage(canvas,this.imgData.src);
  319. // 计算缩放后的图片位置和尺寸
  320. const scaledImgX = imgX * scaleX;
  321. const scaledImgY = imgY * scaleY;
  322. const scaledImgWidth = this.imgData.width * scaleX;
  323. const scaledImgHeight = this.imgData.height * scaleY;
  324. // 绘制图片
  325. if (this.imgData.angle == 0) {
  326. ctx.drawImage(image, scaledImgX, scaledImgY, scaledImgWidth, scaledImgHeight);
  327. } else {
  328. ctx.save();
  329. ctx.translate(scaledImgX + scaledImgWidth / 2, scaledImgY + scaledImgHeight / 2);
  330. ctx.rotate((this.imgData.angle * Math.PI) / 180);
  331. ctx.drawImage(image, -scaledImgWidth / 2, -scaledImgHeight / 2, scaledImgWidth, scaledImgHeight);
  332. ctx.restore();
  333. }
  334. if(this.options.watermark && this.options.watermark.text) {
  335. const text = this.options.watermark.text;
  336. const fontSize = parseInt(this.options.watermark.fontSize) || 18;
  337. const fontFamily = this.options.watermark.fontFamily || 'Arial';
  338. const color = this.options.watermark.color || 'rgba(0, 0, 0, 0.2)';
  339. const rotate = this.options.watermark.rotate || -30;
  340. const spacing = this.options.watermark.spacing || 100;
  341. const single = this.options.watermark.single || false;
  342. const bold = this.options.watermark.bold || false;
  343. // 设置水印样式
  344. ctx.font = `${bold ? 'bold ' : ''}${fontSize}px ${fontFamily}`;
  345. ctx.fillStyle = color;
  346. ctx.textAlign = 'center';
  347. ctx.textBaseline = 'middle';
  348. if (single) {
  349. // 绘制单个居中水印
  350. const centerX = this.width / 2;
  351. const centerY = this.height / 2;
  352. ctx.translate(centerX, centerY);
  353. ctx.rotate((rotate * Math.PI) / 180);
  354. ctx.fillText(text, 0, 0);
  355. } else {
  356. // 绘制网格水印
  357. const cols = Math.ceil(this.width / spacing) + 1;
  358. const rows = Math.ceil(this.height / spacing) + 1;
  359. // 绘制水印网格
  360. for (let row = 0; row < rows; row++) {
  361. for (let col = 0; col < cols; col++) {
  362. const x = col * spacing;
  363. const y = row * spacing;
  364. ctx.save();
  365. ctx.translate(x, y);
  366. ctx.rotate((rotate * Math.PI) / 180);
  367. ctx.fillText(text, 0, 0);
  368. ctx.restore();
  369. }
  370. }
  371. }
  372. }
  373. if(this.options.type == '2d') {
  374. this.canvasToTempFilePath(resolve, reject, canvas);
  375. }else{
  376. ctx.draw(false, ()=>{
  377. this.canvasToTempFilePath(resolve, reject);
  378. });
  379. }
  380. });
  381. }
  382. // 触发更新回调
  383. triggerUpdate() {
  384. this.onUpdate({
  385. imageLoaded: this.imageLoaded,
  386. imageSrc: this.imgData.src,
  387. imageData: { ...this.imgData },
  388. canvasWidth: this.canvasWidth,
  389. canvasHeight: this.canvasHeight,
  390. rectWidth: this.rectWidth,
  391. rectHeight: this.rectHeight
  392. });
  393. }
  394. // 销毁实例
  395. destroy() {
  396. this.imageLoaded = false;
  397. this.imgData = {};
  398. this.touch = {};
  399. this.options = null;
  400. this.onUpdate = null;
  401. }
  402. }