Editor.jsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. /* eslint-disable jsx-a11y/label-has-associated-control */
  2. import Cropper from 'cropperjs'
  3. import { h, Component } from 'preact'
  4. import getCanvasDataThatFitsPerfectlyIntoContainer from './utils/getCanvasDataThatFitsPerfectlyIntoContainer.js'
  5. import getScaleFactorThatRemovesDarkCorners from './utils/getScaleFactorThatRemovesDarkCorners.js'
  6. import limitCropboxMovementOnMove from './utils/limitCropboxMovementOnMove.js'
  7. import limitCropboxMovementOnResize from './utils/limitCropboxMovementOnResize.js'
  8. export default class Editor extends Component {
  9. constructor (props) {
  10. super(props)
  11. this.state = {
  12. angle90Deg: 0,
  13. angleGranular: 0,
  14. prevCropboxData: null,
  15. }
  16. this.storePrevCropboxData = this.storePrevCropboxData.bind(this)
  17. this.limitCropboxMovement = this.limitCropboxMovement.bind(this)
  18. }
  19. componentDidMount () {
  20. const { opts, storeCropperInstance } = this.props
  21. this.cropper = new Cropper(
  22. this.imgElement,
  23. opts.cropperOptions,
  24. )
  25. this.imgElement.addEventListener('cropstart', this.storePrevCropboxData)
  26. this.imgElement.addEventListener('cropend', this.limitCropboxMovement)
  27. storeCropperInstance(this.cropper)
  28. }
  29. componentWillUnmount () {
  30. this.cropper.destroy()
  31. this.imgElement.removeEventListener('cropstart', this.storePrevCropboxData)
  32. this.imgElement.removeEventListener('cropend', this.limitCropboxMovement)
  33. }
  34. // eslint-disable-next-line react/sort-comp
  35. storePrevCropboxData () {
  36. this.setState({ prevCropboxData: this.cropper.getCropBoxData() })
  37. }
  38. limitCropboxMovement (event) {
  39. const canvasData = this.cropper.getCanvasData()
  40. const cropboxData = this.cropper.getCropBoxData()
  41. const { prevCropboxData } = this.state
  42. // 1. When we grab the cropbox in the middle and move it
  43. if (event.detail.action === 'all') {
  44. const newCropboxData = limitCropboxMovementOnMove(canvasData, cropboxData, prevCropboxData)
  45. if (newCropboxData) this.cropper.setCropBoxData(newCropboxData)
  46. // When we stretch the cropbox by one of its sides
  47. } else {
  48. const newCropboxData = limitCropboxMovementOnResize(canvasData, cropboxData, prevCropboxData)
  49. if (newCropboxData) this.cropper.setCropBoxData(newCropboxData)
  50. }
  51. }
  52. onRotate90Deg = () => {
  53. // 1. Set state
  54. const { angle90Deg } = this.state
  55. const newAngle = angle90Deg - 90
  56. this.setState({
  57. angle90Deg: newAngle,
  58. angleGranular: 0,
  59. })
  60. // 2. Rotate the image
  61. // Important to reset scale here, or cropper will get confused on further rotations
  62. this.cropper.scale(1)
  63. this.cropper.rotateTo(newAngle)
  64. // 3. Fit the rotated image into the view
  65. const canvasData = this.cropper.getCanvasData()
  66. const containerData = this.cropper.getContainerData()
  67. const newCanvasData = getCanvasDataThatFitsPerfectlyIntoContainer(containerData, canvasData)
  68. this.cropper.setCanvasData(newCanvasData)
  69. // 4. Make cropbox fully wrap the image
  70. this.cropper.setCropBoxData(newCanvasData)
  71. }
  72. onRotateGranular = (ev) => {
  73. // 1. Set state
  74. const newGranularAngle = Number(ev.target.value)
  75. this.setState({ angleGranular: newGranularAngle })
  76. // 2. Rotate the image
  77. const { angle90Deg } = this.state
  78. const newAngle = angle90Deg + newGranularAngle
  79. this.cropper.rotateTo(newAngle)
  80. // 3. Scale the image so that it fits into the cropbox
  81. const image = this.cropper.getImageData()
  82. const scaleFactor = getScaleFactorThatRemovesDarkCorners(image.naturalWidth, image.naturalHeight, newGranularAngle)
  83. // Preserve flip
  84. const scaleFactorX = this.cropper.getImageData().scaleX < 0 ? -scaleFactor : scaleFactor
  85. this.cropper.scale(scaleFactorX, scaleFactor)
  86. }
  87. renderGranularRotate () {
  88. const { i18n } = this.props
  89. const { angleGranular } = this.state
  90. return (
  91. <label
  92. role="tooltip"
  93. aria-label={`${angleGranular}º`}
  94. data-microtip-position="top"
  95. className="uppy-ImageCropper-rangeWrapper"
  96. >
  97. <input
  98. className="uppy-ImageCropper-range uppy-u-reset"
  99. type="range"
  100. onInput={this.onRotateGranular}
  101. onChange={this.onRotateGranular}
  102. value={angleGranular}
  103. min="-45"
  104. max="45"
  105. aria-label={i18n('rotate')}
  106. />
  107. </label>
  108. )
  109. }
  110. renderRevert () {
  111. const { i18n, opts } = this.props
  112. return (
  113. <label
  114. role="tooltip"
  115. aria-label={i18n('revert')}
  116. data-microtip-position="top"
  117. >
  118. <button
  119. type="button"
  120. className="uppy-u-reset uppy-c-btn"
  121. onClick={() => {
  122. this.cropper.reset()
  123. this.cropper.setAspectRatio(opts.cropperOptions.initialAspectRatio)
  124. this.setState({ angle90Deg: 0, angleGranular: 0 })
  125. }}
  126. >
  127. <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
  128. <path d="M0 0h24v24H0z" fill="none" />
  129. <path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z" />
  130. </svg>
  131. </button>
  132. </label>
  133. )
  134. }
  135. renderRotate () {
  136. const { i18n } = this.props
  137. return (
  138. <label
  139. role="tooltip"
  140. aria-label={i18n('rotate')}
  141. data-microtip-position="top"
  142. >
  143. <button
  144. type="button"
  145. className="uppy-u-reset uppy-c-btn"
  146. onClick={this.onRotate90Deg}
  147. >
  148. <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
  149. <path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none" />
  150. <path d="M14 10a2 2 0 012 2v7a2 2 0 01-2 2H6a2 2 0 01-2-2v-7a2 2 0 012-2h8zm0 1.75H6a.25.25 0 00-.243.193L5.75 12v7a.25.25 0 00.193.243L6 19.25h8a.25.25 0 00.243-.193L14.25 19v-7a.25.25 0 00-.193-.243L14 11.75zM12 .76V4c2.3 0 4.61.88 6.36 2.64a8.95 8.95 0 012.634 6.025L21 13a1 1 0 01-1.993.117L19 13h-.003a6.979 6.979 0 00-2.047-4.95 6.97 6.97 0 00-4.652-2.044L12 6v3.24L7.76 5 12 .76z" />
  151. </svg>
  152. </button>
  153. </label>
  154. )
  155. }
  156. renderFlip () {
  157. const { i18n } = this.props
  158. return (
  159. <label
  160. role="tooltip"
  161. aria-label={i18n('flipHorizontal')}
  162. data-microtip-position="top"
  163. >
  164. <button
  165. type="button"
  166. className="uppy-u-reset uppy-c-btn"
  167. onClick={() => this.cropper.scaleX(-this.cropper.getData().scaleX || -1)}
  168. >
  169. <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
  170. <path d="M0 0h24v24H0z" fill="none" />
  171. <path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8 20h2V1h-2v22zm8-6h2v-2h-2v2zM15 5h2V3h-2v2zm4 8h2v-2h-2v2zm0 8c1.1 0 2-.9 2-2h-2v2z" />
  172. </svg>
  173. </button>
  174. </label>
  175. )
  176. }
  177. renderZoomIn () {
  178. const { i18n } = this.props
  179. return (
  180. <label
  181. role="tooltip"
  182. aria-label={i18n('zoomIn')}
  183. data-microtip-position="top"
  184. >
  185. <button
  186. type="button"
  187. className="uppy-u-reset uppy-c-btn"
  188. onClick={() => this.cropper.zoom(0.1)}
  189. >
  190. <svg aria-hidden="true" className="uppy-c-icon" height="24" viewBox="0 0 24 24" width="24">
  191. <path d="M0 0h24v24H0V0z" fill="none" />
  192. <path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
  193. <path d="M12 10h-2v2H9v-2H7V9h2V7h1v2h2v1z" />
  194. </svg>
  195. </button>
  196. </label>
  197. )
  198. }
  199. renderZoomOut () {
  200. const { i18n } = this.props
  201. return (
  202. <label
  203. role="tooltip"
  204. aria-label={i18n('zoomOut')}
  205. data-microtip-position="top"
  206. >
  207. <button
  208. type="button"
  209. className="uppy-u-reset uppy-c-btn"
  210. onClick={() => this.cropper.zoom(-0.1)}
  211. >
  212. <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
  213. <path d="M0 0h24v24H0V0z" fill="none" />
  214. <path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14zM7 9h5v1H7z" />
  215. </svg>
  216. </button>
  217. </label>
  218. )
  219. }
  220. renderCropSquare () {
  221. const { i18n } = this.props
  222. return (
  223. <label
  224. role="tooltip"
  225. aria-label={i18n('aspectRatioSquare')}
  226. data-microtip-position="top"
  227. >
  228. <button
  229. type="button"
  230. className="uppy-u-reset uppy-c-btn"
  231. onClick={() => this.cropper.setAspectRatio(1)}
  232. >
  233. <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
  234. <path d="M0 0h24v24H0z" fill="none" />
  235. <path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
  236. </svg>
  237. </button>
  238. </label>
  239. )
  240. }
  241. renderCropWidescreen () {
  242. const { i18n } = this.props
  243. return (
  244. <label
  245. role="tooltip"
  246. aria-label={i18n('aspectRatioLandscape')}
  247. data-microtip-position="top"
  248. >
  249. <button
  250. type="button"
  251. className="uppy-u-reset uppy-c-btn"
  252. onClick={() => this.cropper.setAspectRatio(16 / 9)}
  253. >
  254. <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
  255. <path d="M 19,4.9999992 V 17.000001 H 4.9999998 V 6.9999992 H 19 m 0,-2 H 4.9999998 c -1.0999999,0 -1.9999999,0.9000001 -1.9999999,2 V 17.000001 c 0,1.1 0.9,2 1.9999999,2 H 19 c 1.1,0 2,-0.9 2,-2 V 6.9999992 c 0,-1.0999999 -0.9,-2 -2,-2 z" />
  256. <path fill="none" d="M0 0h24v24H0z" />
  257. </svg>
  258. </button>
  259. </label>
  260. )
  261. }
  262. renderCropWidescreenVertical () {
  263. const { i18n } = this.props
  264. return (
  265. <label
  266. role="tooltip"
  267. aria-label={i18n('aspectRatioPortrait')}
  268. data-microtip-position="top"
  269. >
  270. <button
  271. type="button"
  272. className="uppy-u-reset uppy-c-btn"
  273. onClick={() => this.cropper.setAspectRatio(9 / 16)}
  274. >
  275. <svg aria-hidden="true" className="uppy-c-icon" width="24" height="24" viewBox="0 0 24 24">
  276. <path d="M 19.000001,19 H 6.999999 V 5 h 10.000002 v 14 m 2,0 V 5 c 0,-1.0999999 -0.9,-1.9999999 -2,-1.9999999 H 6.999999 c -1.1,0 -2,0.9 -2,1.9999999 v 14 c 0,1.1 0.9,2 2,2 h 10.000002 c 1.1,0 2,-0.9 2,-2 z" />
  277. <path d="M0 0h24v24H0z" fill="none" />
  278. </svg>
  279. </button>
  280. </label>
  281. )
  282. }
  283. render () {
  284. const { currentImage, opts } = this.props
  285. const { actions } = opts
  286. const imageURL = URL.createObjectURL(currentImage.data)
  287. return (
  288. <div className="uppy-ImageCropper">
  289. <div className="uppy-ImageCropper-container">
  290. <img
  291. className="uppy-ImageCropper-image"
  292. alt={currentImage.name}
  293. src={imageURL}
  294. ref={ref => { this.imgElement = ref }}
  295. />
  296. </div>
  297. <div className="uppy-ImageCropper-controls">
  298. {actions.revert && this.renderRevert()}
  299. {actions.rotate && this.renderRotate()}
  300. {actions.granularRotate && this.renderGranularRotate()}
  301. {actions.flip && this.renderFlip()}
  302. {actions.zoomIn && this.renderZoomIn()}
  303. {actions.zoomOut && this.renderZoomOut()}
  304. {actions.cropSquare && this.renderCropSquare()}
  305. {actions.cropWidescreen && this.renderCropWidescreen()}
  306. {actions.cropWidescreenVertical && this.renderCropWidescreenVertical()}
  307. </div>
  308. </div>
  309. )
  310. }
  311. }