cropper.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. const ABS = Math.abs
  2. const calcLen = (v) => { // distance between two coordinate
  3. return Math.sqrt(v.x * v.x + v.y * v.y)
  4. }
  5. const calcAngle = (a, b) => { // angle of the two vectors
  6. var l = calcLen(a) * calcLen(b); var cosValue; var angle
  7. if (l) {
  8. cosValue = (a.x * b.x + a.y * b.y) / l
  9. angle = Math.acos(Math.min(cosValue, 1))
  10. angle = a.x * b.y - b.x * a.y > 0 ? -angle : angle
  11. return angle * 180 / Math.PI
  12. }
  13. return 0
  14. }
  15. const generateCanvasId = () => { // generate a random string
  16. const seeds = 'abcdefghijklmnopqrstuvwxyz'
  17. const arr = seeds.split('').concat(seeds.toUpperCase().split('')).concat('0123456789'.split(''))
  18. let m = arr.length; let i
  19. while (m) {
  20. i = Math.floor(Math.random() * m--)
  21. const temp = arr[m]
  22. arr[m] = arr[i]
  23. arr[i] = temp
  24. }
  25. return arr.slice(0, 16).join('')
  26. }
  27. export default {
  28. props: {
  29. width: { // width of the container
  30. type: [String, Number],
  31. default: '100%'
  32. },
  33. height: { // height of the container
  34. type: [String, Number],
  35. default: '100%'
  36. },
  37. cutWidth: { // cutter width
  38. type: [String, Number],
  39. default: '50%'
  40. },
  41. cutHeight: { // cutter height
  42. type: [String, Number],
  43. default: 0
  44. },
  45. minWidth: { // minWidth of the cutter
  46. type: Number,
  47. default: 50
  48. },
  49. minHeight: { // minHeight of the cutter
  50. type: Number,
  51. default: 50
  52. },
  53. center: { // autoCenter
  54. type: Boolean,
  55. default: true
  56. },
  57. src: String,
  58. disableScale: Boolean, // disable to zoom
  59. disableRotate: Boolean,
  60. disableTranslate: Boolean,
  61. disableCtrl: Boolean, // disable to resize the cutter
  62. boundDetect: Boolean, // open boundary detection
  63. freeBoundDetect: Boolean, // open boundary detection while doing rotation
  64. keepRatio: Boolean, // keep the ratio of the cutter
  65. disablePreview: Boolean, // disable preview after cutting
  66. showCtrlBorder: Boolean, // show cutter border
  67. resetCut: Boolean, // reset cut while img change
  68. fit: {
  69. type: Boolean,
  70. default: true
  71. },
  72. imageCenter: Boolean, // auto center/middle for image
  73. maxZoom: { // maximum scaling factor
  74. type: Number,
  75. default: 10 // can not be Infinity in baidu-MiniProgram
  76. },
  77. minZoom: { // minimum scaling factor
  78. type: Number,
  79. default: 1
  80. },
  81. angle: { // initial angle of rotation
  82. type: Number,
  83. default: 0
  84. },
  85. zoom: { // initial scaling factor
  86. type: Number,
  87. default: 1
  88. },
  89. offset: { // initial offset relative to the cutter left border
  90. type: Array,
  91. default() {
  92. return [0, 0]
  93. }
  94. },
  95. background: {
  96. type: String,
  97. default: '#000'
  98. },
  99. canvasBackground: { // background for the exported image
  100. type: String,
  101. default: '#fff'
  102. },
  103. canvasZoom: { // export multiples of the cutter size
  104. type: Number,
  105. default: 1
  106. },
  107. fileType: {
  108. type: String,
  109. default: 'png',
  110. validator(t) {
  111. return ['png', 'jpg'].includes(t)
  112. }
  113. },
  114. quality: {
  115. type: Number,
  116. default: 1
  117. },
  118. maskType: { // type for mask
  119. type: String,
  120. default: "shadow"
  121. },
  122. circleView: Boolean // circle clip view
  123. },
  124. data() {
  125. return {
  126. transform: {
  127. angle: 0,
  128. translate: {
  129. x: 0,
  130. y: 0
  131. },
  132. zoom: 1
  133. },
  134. corner: {
  135. left: 50,
  136. right: 50,
  137. bottom: 50,
  138. top: 50
  139. },
  140. image: {
  141. originWidth: 0,
  142. originHeight: 0,
  143. width: 0,
  144. height: 0
  145. },
  146. ctrlWidth: 0,
  147. ctrlHeight: 0,
  148. view: false,
  149. canvasId: ''
  150. }
  151. },
  152. computed: {
  153. transformMeta: function() {
  154. const transform = this.transform
  155. return `translate3d(${transform.translate.x}px, ${transform.translate.y}px, 0) rotate(${transform.angle}deg) scale(${transform.zoom})`
  156. },
  157. ctrlStyle: function() {
  158. const corner = this.corner
  159. let cssStr = `left: ${corner.left}px;top: ${corner.top}px;right: ${corner.right}px;bottom: ${corner.bottom }px;`
  160. if(this.maskType !== 'outline') {
  161. cssStr += `box-shadow: 0 0 0 50000rpx rgba(0,0,0, ${this.view ? 0.8 : 0.4})`
  162. } else {
  163. cssStr += `outline: rgba(0,0,0, ${this.view ? 0.8 : 0.4}) solid 5000px`
  164. }
  165. return cssStr
  166. }
  167. },
  168. watch: {
  169. src: function() {
  170. if(this.resetCut) this.resetCutReact()
  171. this.initImage()
  172. }
  173. },
  174. created() {
  175. this.canvasId = generateCanvasId()
  176. uni.getSystemInfo().then(result => {
  177. result = result[1] || {windowWidth: 375, windowHeight: 736}
  178. this.ratio = result.windowWidth / 750
  179. this.windowHeight = result.windowHeight
  180. this.init()
  181. this.initCanvas()
  182. })
  183. },
  184. methods: {
  185. toPx(str) {
  186. if (str.indexOf('%') !== -1) {
  187. return Math.floor(Number(str.replace('%', '')) / 100 * this.containerWidth)
  188. }
  189. if (str.indexOf('rpx') !== -1) {
  190. return Math.floor(Number(str.replace('rpx', '')) * this.ratio)
  191. }
  192. return Math.floor(Number(str.replace('px', '')))
  193. },
  194. initCanvas() {
  195. // #ifdef MP-ALIPAY
  196. const context = uni.createSelectorQuery()
  197. // #endif
  198. // #ifndef MP-ALIPAY
  199. const context = uni.createSelectorQuery().in(this)
  200. // #endif
  201. // get contianer size
  202. context.select('.nice-cropper').boundingClientRect()
  203. context.exec(res => {
  204. this.containerWidth = res[0].width
  205. this.containerHeight = res[0].height
  206. this.initCut()
  207. })
  208. },
  209. resetCutReact() {// init size and position of the cutter
  210. this.ctrlWidth = Math.min(this.toPx(this.cutWidth), this.containerWidth)
  211. if (this.cutHeight) {
  212. this.ctrlHeight = Math.min(this.toPx(this.cutHeight), this.containerHeight)
  213. } else { // 默认为正方形
  214. this.ctrlHeight = Math.min(this.ctrlWidth, this.containerHeight)
  215. }
  216. const cornerStartX = this.center ? Math.floor((this.containerWidth - this.ctrlWidth) / 2) : 0
  217. const cornerStartY = this.center ? Math.floor((this.containerHeight - this.ctrlHeight) / 2) : 0
  218. this.cutRatio = this.ctrlHeight / this.ctrlWidth
  219. this.corner = {
  220. left: cornerStartX,
  221. right: this.containerWidth - this.ctrlWidth - cornerStartX,
  222. top: cornerStartY,
  223. bottom: this.containerHeight - this.ctrlHeight - cornerStartY
  224. }
  225. },
  226. initCut() {
  227. this.resetCutReact()
  228. this.initImage()
  229. },
  230. async initImage() {
  231. if (!this.src) return
  232. const [err, res] = await uni.getImageInfo({
  233. src: this.src
  234. })
  235. if(err) {
  236. this.$emit("error", err)
  237. } else {
  238. this.$emit('load', res)
  239. }
  240. // init image size
  241. this.image.originWidth = err ? this.ctrlWidth : res.width
  242. this.image.originHeight = err ? this.ctrlHeight : res.height
  243. this.image.width = this.fit ? this.ctrlWidth : this.image.originWidth
  244. this.image.height = err ? this.ctrlHeight : res.height / res.width * this.image.width
  245. this.img = res.path
  246. const offset = [0, 0]
  247. if(this.imageCenter) {
  248. offset[0] = (this.ctrlWidth - this.image.width) / 2
  249. offset[1] = (this.ctrlHeight - this.image.height) / 2
  250. }
  251. offset[0] += this.offset[0] || 0
  252. offset[1] += this.offset[1] || 0
  253. this.setTranslate(offset)
  254. this.setZoom(this.zoom)
  255. this.transform.angle = this.freeBoundDetect || !this.disableRotate ? this.angle : 0
  256. this.setBoundary() // boundary detect
  257. this.preview() // preview
  258. this.draw()
  259. },
  260. init() {
  261. this.pretouch = {}
  262. this.handles = {}
  263. this.preVector = {
  264. x: 0,
  265. y: 0
  266. }
  267. this.distance = 30
  268. this.touch = {}
  269. this.movetouch = {}
  270. this.cutMode = false
  271. this.params = {
  272. zoom: 1,
  273. deltaX: 0,
  274. deltaY: 0,
  275. diffX: 0,
  276. diffY: 0,
  277. angle: 0
  278. }
  279. },
  280. start(e) {
  281. if(!this.src) e.preventDefault()
  282. const point = e.touches ? e.touches[0] : e
  283. const touch = this.touch
  284. const now = Date.now()
  285. touch.startX = point.pageX
  286. touch.startY = point.pageY
  287. touch.startTime = now
  288. this.doubleTap = false
  289. this.view = false
  290. clearTimeout(this.previewTimer)
  291. if (e.touches.length > 1) {
  292. var point2 = e.touches[1]
  293. this.preVector = {
  294. x: point2.pageX - this.touch.startX,
  295. y: point2.pageY - this.touch.startY
  296. }
  297. this.startDistance = calcLen(this.preVector)
  298. } else {
  299. let pretouch = this.pretouch
  300. this.doubleTap = this.pretouch.time && now - this.pretouch.time < 300 && ABS(touch.startX - pretouch.startX) < 30 && ABS(touch.startY - pretouch.startY) < 30 && ABS(touch.startTime - pretouch.time) < 300
  301. pretouch = { // reserve the last touch
  302. startX: this.touch.startX,
  303. startY: this.touch.startY,
  304. time: this.touch.startTime
  305. }
  306. }
  307. },
  308. move(e) {
  309. if(!this.src) return
  310. const point = e.touches ? e.touches[0] : e
  311. if (e.touches.length > 1) { // multi touch
  312. const point2 = e.touches[1]
  313. const v = {
  314. x: point2.pageX - point.pageX,
  315. y: point2.pageY - point.pageY
  316. }
  317. if (this.preVector.x !== null) {
  318. if (this.startDistance) { // zoom
  319. const len = calcLen(v)
  320. this.params.zoom = calcLen(v) / this.startDistance
  321. this.startDistance = len
  322. this.cutMode && !this.disableCtrl ? this.setCut() : !this.disableScale && this.pinch()
  323. }
  324. // rotate
  325. this.params.angle = calcAngle(v, this.preVector)
  326. this.cutMode && !this.disableCtrl ? this.setCut() : !this.disableRotate && this.rotate()
  327. }
  328. this.preVector.x = v.x
  329. this.preVector.y = v.y
  330. } else { // translate
  331. const diffX = point.pageX - this.touch.startX
  332. const diffY = point.pageY - this.touch.startY
  333. this.params.diffY = diffY
  334. this.params.diffX = diffX
  335. if (this.movetouch.x) {
  336. this.params.deltaX = point.pageX - this.movetouch.x
  337. this.params.deltaY = point.pageY - this.movetouch.y
  338. } else {
  339. this.params.deltaX = this.params.deltaY = 0
  340. }
  341. if (ABS(diffX) > 30 || ABS(diffY) > 30) {
  342. this.doubleTap = false
  343. }
  344. this.cutMode && !this.disableCtrl ? this.setCut() : !this.disableTranslate && this.translate()
  345. this.movetouch.x = point.pageX
  346. this.movetouch.y = point.pageY
  347. }
  348. !this.cutMode && this.setBoundary()
  349. if (e.touches.length > 1) {
  350. e.preventDefault()
  351. }
  352. },
  353. end() {
  354. this.doubleTap && this.$emit('doubleTap')
  355. this.cutMode && this.setBoundary()
  356. this.init()
  357. !this.disablePreview && this.preview()
  358. this.draw()
  359. },
  360. translate() {
  361. const transform = this.transform.translate
  362. const meta = this.params
  363. transform.x += meta.deltaX
  364. transform.y += meta.deltaY
  365. },
  366. pinch() {
  367. this.transform.zoom *= this.params.zoom
  368. },
  369. rotate() {
  370. this.transform.angle += this.params.angle
  371. },
  372. setZoom(scale) {
  373. scale = Math.min(Math.max(Number(scale) || 1, this.minZoom), this.maxZoom)
  374. this.transform.zoom = scale
  375. },
  376. setTranslate(offset) {
  377. if(Array.isArray(offset)) {
  378. const x = Number(offset[0])
  379. const y = Number(offset[1])
  380. this.transform.translate.x = isNaN(x) ? this.transform.translate.x : this.corner.left + x
  381. this.transform.translate.y = isNaN(y) ? this.transform.translate.y : this.corner.top + y
  382. }
  383. },
  384. setRotate(angle) {
  385. this.transform.angle = Number(angle) || 0
  386. },
  387. setTransform(x, y, angle, scale) {
  388. this.setTranslate([x, y])
  389. this.setZoom(scale)
  390. this.setRotate(angle)
  391. },
  392. setCutMode(type) {
  393. if(!this.src) return
  394. this.cutMode = true
  395. this.cutDirection = type
  396. },
  397. setCut() {
  398. const corner = this.corner
  399. const meta = this.params
  400. this.setMeta(this.cutDirection, meta) // correct cutter position
  401. if (this.keepRatio) {
  402. if (this.cutDirection === 'lt' || this.cutDirection === 'rb') {
  403. meta.deltaY = meta.deltaX * this.cutRatio
  404. } else {
  405. meta.deltaX = meta.deltaY / this.cutRatio
  406. }
  407. }
  408. switch (this.cutDirection) {
  409. case 'lt':
  410. corner.top += meta.deltaY
  411. corner.left += meta.deltaX
  412. break
  413. case 'rt':
  414. corner.top += meta.deltaY
  415. corner.right -= this.keepRatio ? -meta.deltaX : meta.deltaX
  416. break
  417. case 'rb':
  418. corner.right -= meta.deltaX
  419. corner.bottom -= meta.deltaY
  420. break
  421. case 'lb':
  422. corner.bottom -= meta.deltaY
  423. corner.left += this.keepRatio ? -meta.deltaX : meta.deltaX
  424. break
  425. }
  426. this.ctrlWidth = this.containerWidth - corner.left - corner.right
  427. this.ctrlHeight = this.containerHeight - corner.top - corner.bottom
  428. },
  429. setMeta(direction, meta) {
  430. const {ctrlWidth, ctrlHeight, minWidth, minHeight } = this
  431. switch(direction) {
  432. case 'lt':
  433. if(meta.deltaX > 0 || meta.deltaY > 0) {
  434. meta.deltaX = Math.min(meta.deltaX, ctrlWidth - minWidth)
  435. meta.deltaY = Math.min(meta.deltaY, ctrlHeight - minHeight)
  436. }
  437. break
  438. case 'rt':
  439. if(meta.deltaX < 0 || meta.deltaY > 0) {
  440. meta.deltaX = Math.max(meta.deltaX, minWidth - ctrlWidth)
  441. meta.deltaY = Math.min(meta.deltaY, ctrlHeight - minHeight)
  442. }
  443. break
  444. case 'rb':
  445. if(meta.deltaX < 0 || meta.deltaY < 0) {
  446. meta.deltaX = Math.max(meta.deltaX, minWidth - ctrlWidth)
  447. meta.deltaY = Math.max(meta.deltaY, minHeight - ctrlHeight)
  448. }
  449. break
  450. case 'lb':
  451. if(meta.deltaX > 0 || meta.deltaY < 0) {
  452. meta.deltaX = Math.min(meta.deltaX, ctrlWidth - minWidth)
  453. meta.deltaY = Math.max(meta.deltaY, minHeight - ctrlHeight)
  454. }
  455. break
  456. }
  457. },
  458. setBoundary() {
  459. let zoom = this.transform.zoom
  460. zoom = zoom < this.minZoom ? this.minZoom : (zoom > this.maxZoom ? this.maxZoom : zoom)
  461. this.transform.zoom = zoom
  462. if (!this.boundDetect || !this.disableRotate && !this.freeBoundDetect) return true
  463. const translate = this.transform.translate
  464. const corner = this.corner
  465. const minX = corner.left - this.image.width + this.ctrlWidth - this.image.width * (zoom - 1) / 2
  466. const maxX = corner.left + this.image.width * (zoom - 1) / 2
  467. const minY = corner.top - this.image.height + this.ctrlHeight - this.image.height * (zoom - 1) / 2
  468. const maxY = corner.top + this.image.height * (zoom - 1) / 2
  469. translate.x = Math.floor(translate.x < minX ? minX : (translate.x > maxX ? maxX : translate.x))
  470. translate.y = Math.floor(translate.y < minY ? minY : (translate.y > maxY ? maxY : translate.y))
  471. },
  472. preview() {
  473. clearTimeout(this.previewTimer)
  474. this.previewTimer = setTimeout(() => {
  475. this.view = true
  476. }, 500)
  477. },
  478. draw() {
  479. // #ifdef MP-ALIPAY
  480. const context = uni.createCanvasContext(this.canvasId)
  481. // #endif
  482. // #ifndef MP-ALIPAY
  483. const context = uni.createCanvasContext(this.canvasId, this)
  484. // #endif
  485. const transform = this.transform
  486. const corner = this.corner
  487. const canvasZoom = this.canvasZoom
  488. const img = this.image
  489. context.save()
  490. context.setFillStyle(this.canvasBackground)
  491. this.$emit('beforeDraw', context, transform) // beforeDraw hook
  492. const zoom = transform.zoom
  493. context.fillRect(0, 0, this.ctrlWidth * canvasZoom, this.ctrlHeight * canvasZoom) // clear canvas
  494. context.translate((transform.translate.x - corner.left + img.width / 2) *canvasZoom, (transform.translate.y - corner.top + img.height / 2) * canvasZoom) // translate the canvas's orgin to the image center
  495. context.rotate(transform.angle * Math.PI / 180)
  496. context.translate(-img.width * zoom * 0.5 * canvasZoom, -img.height * zoom * 0.5 * canvasZoom)
  497. context.drawImage(this.img, 0, 0, img.width * zoom * canvasZoom, img.height * zoom * canvasZoom)
  498. context.restore()
  499. this.$emit('afterDraw', context, {
  500. width: this.ctrlWidth * canvasZoom,
  501. height: this.ctrlHeight * canvasZoom
  502. }) // afterDraw hook
  503. context.draw(false, () => {
  504. uni.canvasToTempFilePath({
  505. canvasId: this.canvasId,
  506. quality: this.quality || 1,
  507. fileType: this.fileType,
  508. success: (res) => {
  509. this.$emit('cropped', res.tempFilePath, {
  510. originWidth: this.image.originWidth,
  511. originHeight: this.image.originHeight,
  512. width: this.ctrlWidth * canvasZoom,
  513. height: this.ctrlHeight * canvasZoom,
  514. scale: zoom,
  515. translate: {
  516. x: transform.translate.x,
  517. y: transform.translate.y
  518. },
  519. rotate: transform.angle
  520. }) // draw callback
  521. }
  522. }, this)
  523. })
  524. }
  525. }
  526. }