cropper.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  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. console.log(res.path)
  241. // init image size
  242. this.image.originWidth = err ? this.ctrlWidth : res.width
  243. this.image.originHeight = err ? this.ctrlHeight : res.height
  244. this.image.width = this.fit ? this.ctrlWidth : this.image.originWidth
  245. this.image.height = err ? this.ctrlHeight : res.height / res.width * this.image.width
  246. this.img = res.path
  247. const offset = [0, 0]
  248. if(this.imageCenter) {
  249. offset[0] = (this.ctrlWidth - this.image.width) / 2
  250. offset[1] = (this.ctrlHeight - this.image.height) / 2
  251. }
  252. offset[0] += this.offset[0] || 0
  253. offset[1] += this.offset[1] || 0
  254. this.setTranslate(offset)
  255. this.setZoom(this.zoom)
  256. this.transform.angle = this.freeBoundDetect || !this.disableRotate ? this.angle : 0
  257. this.setBoundary() // boundary detect
  258. this.preview() // preview
  259. this.draw()
  260. },
  261. init() {
  262. this.pretouch = {}
  263. this.handles = {}
  264. this.preVector = {
  265. x: 0,
  266. y: 0
  267. }
  268. this.distance = 30
  269. this.touch = {}
  270. this.movetouch = {}
  271. this.cutMode = false
  272. this.params = {
  273. zoom: 1,
  274. deltaX: 0,
  275. deltaY: 0,
  276. diffX: 0,
  277. diffY: 0,
  278. angle: 0
  279. }
  280. },
  281. start(e) {
  282. if(!this.src) e.preventDefault()
  283. const point = e.touches ? e.touches[0] : e
  284. const touch = this.touch
  285. const now = Date.now()
  286. touch.startX = point.pageX
  287. touch.startY = point.pageY
  288. touch.startTime = now
  289. this.doubleTap = false
  290. this.view = false
  291. clearTimeout(this.previewTimer)
  292. if (e.touches.length > 1) {
  293. var point2 = e.touches[1]
  294. this.preVector = {
  295. x: point2.pageX - this.touch.startX,
  296. y: point2.pageY - this.touch.startY
  297. }
  298. this.startDistance = calcLen(this.preVector)
  299. } else {
  300. let pretouch = this.pretouch
  301. 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
  302. pretouch = { // reserve the last touch
  303. startX: this.touch.startX,
  304. startY: this.touch.startY,
  305. time: this.touch.startTime
  306. }
  307. }
  308. },
  309. move(e) {
  310. if(!this.src) return
  311. const point = e.touches ? e.touches[0] : e
  312. if (e.touches.length > 1) { // multi touch
  313. const point2 = e.touches[1]
  314. const v = {
  315. x: point2.pageX - point.pageX,
  316. y: point2.pageY - point.pageY
  317. }
  318. if (this.preVector.x !== null) {
  319. if (this.startDistance) { // zoom
  320. const len = calcLen(v)
  321. this.params.zoom = calcLen(v) / this.startDistance
  322. this.startDistance = len
  323. this.cutMode && !this.disableCtrl ? this.setCut() : !this.disableScale && this.pinch()
  324. }
  325. // rotate
  326. this.params.angle = calcAngle(v, this.preVector)
  327. this.cutMode && !this.disableCtrl ? this.setCut() : !this.disableRotate && this.rotate()
  328. }
  329. this.preVector.x = v.x
  330. this.preVector.y = v.y
  331. } else { // translate
  332. const diffX = point.pageX - this.touch.startX
  333. const diffY = point.pageY - this.touch.startY
  334. this.params.diffY = diffY
  335. this.params.diffX = diffX
  336. if (this.movetouch.x) {
  337. this.params.deltaX = point.pageX - this.movetouch.x
  338. this.params.deltaY = point.pageY - this.movetouch.y
  339. } else {
  340. this.params.deltaX = this.params.deltaY = 0
  341. }
  342. if (ABS(diffX) > 30 || ABS(diffY) > 30) {
  343. this.doubleTap = false
  344. }
  345. this.cutMode && !this.disableCtrl ? this.setCut() : !this.disableTranslate && this.translate()
  346. this.movetouch.x = point.pageX
  347. this.movetouch.y = point.pageY
  348. }
  349. !this.cutMode && this.setBoundary()
  350. if (e.touches.length > 1) {
  351. e.preventDefault()
  352. }
  353. },
  354. end() {
  355. this.doubleTap && this.$emit('doubleTap')
  356. this.cutMode && this.setBoundary()
  357. this.init()
  358. !this.disablePreview && this.preview()
  359. this.draw()
  360. },
  361. translate() {
  362. const transform = this.transform.translate
  363. const meta = this.params
  364. transform.x += meta.deltaX
  365. transform.y += meta.deltaY
  366. },
  367. pinch() {
  368. this.transform.zoom *= this.params.zoom
  369. },
  370. rotate() {
  371. this.transform.angle += this.params.angle
  372. },
  373. setZoom(scale) {
  374. scale = Math.min(Math.max(Number(scale) || 1, this.minZoom), this.maxZoom)
  375. this.transform.zoom = scale
  376. },
  377. setTranslate(offset) {
  378. if(Array.isArray(offset)) {
  379. const x = Number(offset[0])
  380. const y = Number(offset[1])
  381. this.transform.translate.x = isNaN(x) ? this.transform.translate.x : this.corner.left + x
  382. this.transform.translate.y = isNaN(y) ? this.transform.translate.y : this.corner.top + y
  383. }
  384. },
  385. setRotate(angle) {
  386. this.transform.angle = Number(angle) || 0
  387. },
  388. setTransform(x, y, angle, scale) {
  389. this.setTranslate([x, y])
  390. this.setZoom(scale)
  391. this.setRotate(angle)
  392. },
  393. setCutMode(type) {
  394. if(!this.src) return
  395. this.cutMode = true
  396. this.cutDirection = type
  397. },
  398. setCut() {
  399. const corner = this.corner
  400. const meta = this.params
  401. this.setMeta(this.cutDirection, meta) // correct cutter position
  402. if (this.keepRatio) {
  403. if (this.cutDirection === 'lt' || this.cutDirection === 'rb') {
  404. meta.deltaY = meta.deltaX * this.cutRatio
  405. } else {
  406. meta.deltaX = meta.deltaY / this.cutRatio
  407. }
  408. }
  409. switch (this.cutDirection) {
  410. case 'lt':
  411. corner.top += meta.deltaY
  412. corner.left += meta.deltaX
  413. break
  414. case 'rt':
  415. corner.top += meta.deltaY
  416. corner.right -= this.keepRatio ? -meta.deltaX : meta.deltaX
  417. break
  418. case 'rb':
  419. corner.right -= meta.deltaX
  420. corner.bottom -= meta.deltaY
  421. break
  422. case 'lb':
  423. corner.bottom -= meta.deltaY
  424. corner.left += this.keepRatio ? -meta.deltaX : meta.deltaX
  425. break
  426. }
  427. this.ctrlWidth = this.containerWidth - corner.left - corner.right
  428. this.ctrlHeight = this.containerHeight - corner.top - corner.bottom
  429. },
  430. setMeta(direction, meta) {
  431. const {ctrlWidth, ctrlHeight, minWidth, minHeight } = this
  432. switch(direction) {
  433. case 'lt':
  434. if(meta.deltaX > 0 || meta.deltaY > 0) {
  435. meta.deltaX = Math.min(meta.deltaX, ctrlWidth - minWidth)
  436. meta.deltaY = Math.min(meta.deltaY, ctrlHeight - minHeight)
  437. }
  438. break
  439. case 'rt':
  440. if(meta.deltaX < 0 || meta.deltaY > 0) {
  441. meta.deltaX = Math.max(meta.deltaX, minWidth - ctrlWidth)
  442. meta.deltaY = Math.min(meta.deltaY, ctrlHeight - minHeight)
  443. }
  444. break
  445. case 'rb':
  446. if(meta.deltaX < 0 || meta.deltaY < 0) {
  447. meta.deltaX = Math.max(meta.deltaX, minWidth - ctrlWidth)
  448. meta.deltaY = Math.max(meta.deltaY, minHeight - ctrlHeight)
  449. }
  450. break
  451. case 'lb':
  452. if(meta.deltaX > 0 || meta.deltaY < 0) {
  453. meta.deltaX = Math.min(meta.deltaX, ctrlWidth - minWidth)
  454. meta.deltaY = Math.max(meta.deltaY, minHeight - ctrlHeight)
  455. }
  456. break
  457. }
  458. },
  459. setBoundary() {
  460. let zoom = this.transform.zoom
  461. zoom = zoom < this.minZoom ? this.minZoom : (zoom > this.maxZoom ? this.maxZoom : zoom)
  462. this.transform.zoom = zoom
  463. if (!this.boundDetect || !this.disableRotate && !this.freeBoundDetect) return true
  464. const translate = this.transform.translate
  465. const corner = this.corner
  466. const minX = corner.left - this.image.width + this.ctrlWidth - this.image.width * (zoom - 1) / 2
  467. const maxX = corner.left + this.image.width * (zoom - 1) / 2
  468. const minY = corner.top - this.image.height + this.ctrlHeight - this.image.height * (zoom - 1) / 2
  469. const maxY = corner.top + this.image.height * (zoom - 1) / 2
  470. translate.x = Math.floor(translate.x < minX ? minX : (translate.x > maxX ? maxX : translate.x))
  471. translate.y = Math.floor(translate.y < minY ? minY : (translate.y > maxY ? maxY : translate.y))
  472. },
  473. preview() {
  474. clearTimeout(this.previewTimer)
  475. this.previewTimer = setTimeout(() => {
  476. this.view = true
  477. }, 500)
  478. },
  479. draw() {
  480. // #ifdef MP-ALIPAY
  481. const context = uni.createCanvasContext(this.canvasId)
  482. // #endif
  483. // #ifndef MP-ALIPAY
  484. const context = uni.createCanvasContext(this.canvasId, this)
  485. // #endif
  486. const transform = this.transform
  487. const corner = this.corner
  488. const canvasZoom = this.canvasZoom
  489. const img = this.image
  490. context.save()
  491. context.setFillStyle(this.canvasBackground)
  492. this.$emit('beforeDraw', context, transform) // beforeDraw hook
  493. const zoom = transform.zoom
  494. context.fillRect(0, 0, this.ctrlWidth * canvasZoom, this.ctrlHeight * canvasZoom) // clear canvas
  495. 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
  496. context.rotate(transform.angle * Math.PI / 180)
  497. context.translate(-img.width * zoom * 0.5 * canvasZoom, -img.height * zoom * 0.5 * canvasZoom)
  498. context.drawImage(this.img, 0, 0, img.width * zoom * canvasZoom, img.height * zoom * canvasZoom)
  499. context.restore()
  500. this.$emit('afterDraw', context, {
  501. width: this.ctrlWidth * canvasZoom,
  502. height: this.ctrlHeight * canvasZoom
  503. }) // afterDraw hook
  504. context.draw(false, () => {
  505. uni.canvasToTempFilePath({
  506. canvasId: this.canvasId,
  507. quality: this.quality || 1,
  508. fileType: this.fileType,
  509. success: (res) => {
  510. this.$emit('cropped', res.tempFilePath, {
  511. originWidth: this.image.originWidth,
  512. originHeight: this.image.originHeight,
  513. width: this.ctrlWidth * canvasZoom,
  514. height: this.ctrlHeight * canvasZoom,
  515. scale: zoom,
  516. translate: {
  517. x: transform.translate.x,
  518. y: transform.translate.y
  519. },
  520. rotate: transform.angle
  521. }) // draw callback
  522. }
  523. }, this)
  524. })
  525. }
  526. }
  527. }