├── .babelrc ├── .browserslistrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .prettierrc.js ├── README.md ├── example └── example.gif ├── package.json ├── rollup.config.js ├── scripts ├── clean.js └── styles.js ├── src ├── components │ ├── Cropper.scss │ ├── Cropper.tsx │ ├── CropperWrapper.scss │ ├── CropperWrapper.tsx │ ├── Navigation.scss │ ├── Navigation.tsx │ ├── RotateComponent.scss │ └── RotateComponent.tsx ├── icons │ ├── FlipHorizontalIcon.tsx │ ├── FlipVerticalIcon.tsx │ ├── RotateLeftIcon.tsx │ ├── RotateRightIcon.tsx │ └── Spinner.tsx ├── index.ts ├── service │ └── utils.ts ├── style.basic.scss ├── style.scss └── styles │ ├── constants.scss │ └── mixins.scss ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false 5 | }], 6 | "stage-0", 7 | "react" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | Firefox ESR 4 | ie >= 11 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier/@typescript-eslint", 7 | "plugin:prettier/recommended", 8 | "plugin:react-hooks/recommended" 9 | ], 10 | "plugins": [ 11 | "react", 12 | "@typescript-eslint", 13 | "prettier", 14 | "eslint-plugin-import" 15 | ], 16 | "env": { 17 | "browser": true, 18 | "jasmine": true, 19 | "jest": true, 20 | "es6": true, 21 | "node": true 22 | }, 23 | "rules": { 24 | "prettier/prettier": [ 25 | "error", 26 | { 27 | "singleQuote": true 28 | } 29 | ], 30 | "@typescript-eslint/explicit-function-return-type": "off", 31 | "@typescript-eslint/explicit-member-accessibility": "off", 32 | "import/order": "warn" 33 | }, 34 | "settings": { 35 | "react": { 36 | "pragma": "React", 37 | "version": "detect" 38 | } 39 | }, 40 | "parser": "@typescript-eslint/parser" 41 | } 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | .vscode 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | stats.html 26 | 27 | # documentation is not ready 28 | docs 29 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'all', 3 | endOfLine: 'auto', 4 | semi: true, 5 | singleQuote: true, 6 | printWidth: 120, 7 | useTabs: true, 8 | tabWidth: 4, 9 | }; 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Mobile Cropper 2 | 3 | The react mobile cropper is highly inspirited by popular Android mobile croppers. It was created as the demonstration of [react-advanced-cropper](https://github.com/norserium/react-advanced-cropper) possibilities and uses it under the hood. Therefore, its documentation would be helpful in the edge cases. 4 | 5 | 6 | [Demo](https://advanced-cropper.github.io/react-advanced-cropper/#mobile-cropper) / [Sandbox](https://codesandbox.io/s/react-mobile-cropper-c1bz2) 7 | 8 | [](https://www.npmjs.com/package/react-mobile-cropper) 9 | 10 | 11 |  12 | 13 | ## Install 14 | 15 | ```bash 16 | npm install --save react-mobile-cropper 17 | ``` 18 | 19 | ```bash 20 | yarn add react-mobile-cropper 21 | ``` 22 | 23 | ## Usage 24 | 25 | ```tsx 26 | import React, { useState } from 'react'; 27 | import { CropperRef, Cropper } from 'react-mobile-cropper'; 28 | import 'react-mobile-cropper/dist/style.css' 29 | 30 | export const GettingStartedExample = () => { 31 | const [image, setImage] = useState( 32 | 'https://images.unsplash.com/photo-1599140849279-1014532882fe?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1300&q=80', 33 | ); 34 | 35 | const onChange = (cropper: CropperRef) => { 36 | console.log(cropper.getCoordinates(), cropper.getCanvas()); 37 | }; 38 | 39 | return ( 40 | 45 | ) 46 | }; 47 | ``` 48 | 49 | ## FAQ 50 | 51 | ### How to change the cropper's main color? 52 | 53 | To change the cropper's main color you should redefine `color` property for the cropper. 54 | ```css 55 | .cropper { 56 | color: red; 57 | } 58 | ``` 59 | ```jsx 60 | 64 | ``` 65 | 66 | 67 | ## Cropper 68 | 69 | | Prop | Type | Description | Default 70 | | ------------------------- |---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------| --------------- 71 | | src | `string` | The cropping image (link / base64) | 72 | | stencilType | `'circle'`, `'rectangle'` | The type of stencil | `'rectangle'` 73 | | stencilComponent | `Component` | The stencil component | `RectangleStencil` 74 | | stencilProps | `object` | The props for the stencil component | `{}` 75 | | className | `string` | The optional class for the root cropper block | 76 | | imageClassName | `string` | The optional class for the cropping image | 77 | | boundaryClassName | `string` | The optional class for the area. | 78 | | backgroundClassName | `string` | The optional class for the background under the image | 79 | | transitions | `boolean`, `object` | Enable / disable auto zoom | `false` 80 | | canvas | `boolean` | The flag that indicates if canvas should be used | `true` 81 | | minWidth | `number` | The minimum width of the stencil (percents) | 82 | | minHeight | `number` | The minimum height of the stencil (percents) | 83 | | maxWidth | `number` | The maximum width of the stencil (percents) | 84 | | maxHeight | `number` | The maximum height of the stencil (percents) | 85 | | checkOrientation | `boolean` | Check if EXIF orientation should be checked | `true` 86 | | resizeImage | `boolean`, `object` | The options for the image resizing ([details](https://advanced-cropper.github.io/react-mobile-cropper/docs/components/Cropper#resizeimage)) | `true` 87 | | moveImage | `boolean`, `object` | The options for the image moving ([details](https://advanced-cropper.github.io/react-mobile-cropper/docs/components/Cropper#moveimage)) | `true` 88 | | imageRestriction | `string` | Set restrictions for image position ('stencil', 'none') | `'stencil'` 89 | | defaultSize | `object`, `Function` | The function that returns the default size of the stencil or object | 90 | | defaultPosition | `object`, `Function` | The function that returns the default position of the stencil or object | 91 | 92 | See [the documentation](https://advanced-cropper.github.io/react-mobile-cropper/docs/components/Cropper) for more props and details. 93 | Notice, in this cropper props `transitions`, `priority`, and `stencilSize` are not available. 94 | 95 | ## RectangleStencil 96 | 97 | | Prop | Type | Description | Default 98 | | ------------------------ | ------------------- | -------------------------------------------------------------- | --------------- 99 | | aspectRatio | `number` | The aspect ratio | 100 | | minAspectRatio | `number` | The minimum aspect ratio | 101 | | maxAspectRatio | `number` | The maximum aspect ratio | 102 | | className | `string` | The class for root block of the stencil component | 103 | | previewClassName | `string` | The class for the preview component | 104 | | movingClassName | `string` | The class applied when user drag the stencil | 105 | | resizingClassName | `string` | The class applied when user resize the stencil | 106 | | boundingBoxClass | `string` | The class for the bounding box component | 107 | | handlerComponent | `Component` | The handler component | 108 | | handlers | `object` | The object of handlers that should be visible or hidden. | 109 | | handlerClassNames | `object` | The object of custom handler classes | 110 | | handlerWrapperClassNames | `object` | The object of custom handler wrapper classes | 111 | | lineComponent | `Component` | The handler component | 112 | | lines | `object` | The object of lines that should be visible or hidden. | 113 | | lineClassNames | `object` | The object of custom line classes | 114 | | lineWrapperClassNames | `object` | The object of custom line wrapper classes | 115 | 116 | See [the documentation](https://advanced-cropper.github.io/react-mobile-cropper/docs/components/RectangleStencil) for more props and details. 117 | 118 | ## License 119 | 120 | The source code of this library is licensed under MIT, the documentation content belongs to [Norserium](https://github.com/Norserium), except the photos that belong to their respective owners. 121 | -------------------------------------------------------------------------------- /example/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/advanced-cropper/react-mobile-cropper/e45dd44e80452d5122c2a0f638dc625938c7d3e7/example/example.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-mobile-cropper", 3 | "version": "0.10.0", 4 | "description": "The react mobile cropper inspired by Android mobile croppers", 5 | "author": "Norserium", 6 | "license": "MIT", 7 | "repository": "Norserium/react-mobile-cropper", 8 | "main": "dist/index.cjs.js", 9 | "unpkg": "dist/index.global.js", 10 | "jsdelivr": "dist/index.global.js", 11 | "module": "dist/index.esm-bundler.js", 12 | "types": "dist/index.d.ts", 13 | "keywords": [ 14 | "react", 15 | "cropper", 16 | "mobile", 17 | "library", 18 | "advanced", 19 | "lightweight", 20 | "customizable", 21 | "flex" 22 | ], 23 | "engines": { 24 | "node": ">=8", 25 | "npm": ">=5" 26 | }, 27 | "scripts": { 28 | "test": "cross-env CI=1 react-scripts test --env=jsdom", 29 | "test:watch": "react-scripts test --env=jsdom", 30 | "build": "npm run build:clean && npm run build:library && npm run build:styles", 31 | "build:clean": "node scripts/clean.js", 32 | "build:library": "rollup -c", 33 | "build:styles": "node scripts/styles.js", 34 | "start": "rollup -c -w", 35 | "prepare": "yarn run build", 36 | "predeploy": "cd example && yarn build", 37 | "deploy": "gh-pages -d example/build" 38 | }, 39 | "peerDependencies": { 40 | "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" 41 | }, 42 | "devDependencies": { 43 | "@rollup/plugin-babel": "^5.3.0", 44 | "@rollup/plugin-commonjs": "^22.0.2", 45 | "@rollup/plugin-node-resolve": "^13.3.0", 46 | "@rollup/plugin-replace": "^3.0.0", 47 | "@rollup/plugin-typescript": "^8.2.5", 48 | "@types/debounce": "^1.2.0", 49 | "@types/node": "^14.14.7", 50 | "@types/react": "^16.9.56", 51 | "@typescript-eslint/eslint-plugin": "^1.6.0", 52 | "@typescript-eslint/parser": "^1.6.0", 53 | "@wessberg/rollup-plugin-ts": "^1.3.14", 54 | "cross-env": "^5.1.4", 55 | "cssnano": "^5.0.8", 56 | "eslint-config-prettier": "^4.1.0", 57 | "eslint-config-react": "^1.1.7", 58 | "eslint-plugin-import": "^2.24.2", 59 | "eslint-plugin-node": "^7.0.1", 60 | "eslint-plugin-prettier": "^3.0.1", 61 | "eslint-plugin-promise": "^4.0.0", 62 | "eslint-plugin-react": "^7.10.0", 63 | "eslint-plugin-react-hooks": "^4.2.0", 64 | "eslint-plugin-standard": "^3.1.0", 65 | "fs-extra": "9.1.0", 66 | "gh-pages": "^1.2.0", 67 | "glob": "^8.0.3", 68 | "prettier": "^2.4.0", 69 | "react": "^17.0.0", 70 | "react-dom": "^17.0.0", 71 | "react-scripts": "^2.1.8", 72 | "rollup": "^2.78.0", 73 | "rollup-plugin-terser": "^7.0.2", 74 | "rollup-plugin-visualizer": "^5.6.0", 75 | "sass": "^1.54.4", 76 | "typescript": "^4.0.5" 77 | }, 78 | "files": [ 79 | "dist" 80 | ], 81 | "dependencies": { 82 | "classnames": "^2.2.6", 83 | "react-advanced-cropper": "~0.19.3", 84 | "tslib": "^2.4.0" 85 | }, 86 | "sideEffects": ["**/*.css"] 87 | } 88 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import typescript from '@rollup/plugin-typescript'; 4 | import replace from '@rollup/plugin-replace'; 5 | import { visualizer } from 'rollup-plugin-visualizer'; 6 | import { terser } from 'rollup-plugin-terser'; 7 | import pkg from './package.json'; 8 | 9 | const bundles = [ 10 | { 11 | format: 'es', 12 | bundle: true, 13 | file: pkg.module, 14 | }, 15 | { 16 | format: 'cjs', 17 | bundle: true, 18 | file: pkg.main, 19 | }, 20 | { 21 | format: 'iife', 22 | bundle: false, 23 | file: pkg.unpkg, 24 | }, 25 | ]; 26 | 27 | export default bundles.map(({ format, bundle, file }) => ({ 28 | input: 'src/index.ts', 29 | output: { 30 | file, 31 | format, 32 | name: !bundle ? 'ReactMobileCropper' : undefined, 33 | globals: { 34 | react: 'React', 35 | 'react-advanced-cropper': 'ReactAdvancedCropper', 36 | }, 37 | sourcemap: true, 38 | }, 39 | external: bundle ? [/node_modules/] : ['react', 'react-advanced-cropper'], 40 | plugins: [ 41 | resolve(), 42 | commonjs(), 43 | typescript({ tsconfig: './tsconfig.json' }), 44 | !bundle && 45 | terser({ 46 | format: { 47 | comments: false, 48 | }, 49 | module: format === 'es', 50 | }), 51 | visualizer({ 52 | gzipSize: true, 53 | }), 54 | !bundle && 55 | replace({ 56 | 'process.env.NODE_ENV': 'production', 57 | }), 58 | ], 59 | })); 60 | -------------------------------------------------------------------------------- /scripts/clean.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | async function removeDirectory(directory) { 4 | fs.rmdirSync(directory, { recursive: true, force: true }); 5 | } 6 | 7 | async function run() { 8 | try { 9 | await removeDirectory('./dist'); 10 | } catch (err) { 11 | console.error(err); 12 | process.exit(1); 13 | } 14 | } 15 | 16 | run(); 17 | -------------------------------------------------------------------------------- /scripts/styles.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs-extra'); 3 | const sass = require('sass'); 4 | const autoprefixer = require('autoprefixer'); 5 | const postcss = require('postcss'); 6 | const glob = require('glob'); 7 | 8 | async function copyStyleSources(source, dist) { 9 | const files = glob.sync(`**/*.scss`, { cwd: source }); 10 | await Promise.all( 11 | files.map(async (file) => { 12 | await fs.ensureDir(path.dirname(`${dist}/${file}`)); 13 | await fs.copyFile(`${source}/${file}`, `${dist}/${file}`); 14 | }), 15 | ); 16 | } 17 | 18 | async function buildStyles(styles, dist) { 19 | await Promise.all( 20 | styles.map(async (style) => { 21 | const { css } = await sass.compileAsync(style, { 22 | loadPaths: ['node_modules'], 23 | }); 24 | 25 | postcss([autoprefixer]) 26 | .process(css) 27 | .then((result) => { 28 | result.warnings().forEach((warn) => { 29 | console.warn(warn.toString()); 30 | }); 31 | fs.writeFileSync(`${dist}/${path.basename(style, path.extname(style))}.css`, result.css); 32 | }); 33 | }), 34 | ); 35 | } 36 | 37 | async function run() { 38 | try { 39 | await Promise.all([ 40 | copyStyleSources('./src', './dist'), 41 | buildStyles(['./src/style.scss', './src/style.basic.scss'], './dist'), 42 | ]); 43 | } catch (err) { 44 | console.error(err); 45 | process.exit(1); 46 | } 47 | } 48 | 49 | run(); 50 | -------------------------------------------------------------------------------- /src/components/Cropper.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/constants'; 2 | 3 | .rmc-cropper { 4 | color: $rmc-main-color; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Cropper.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useRef } from 'react'; 2 | import cn from 'classnames'; 3 | import { 4 | Cropper as DefaultCropper, 5 | CropperProps as DefaultCropperProps, 6 | CropperRef, 7 | ImageRestriction, 8 | mergeRefs, 9 | ScaleImageOptions, 10 | } from 'react-advanced-cropper'; 11 | import { 12 | fitStencilToImage, 13 | zoomStencil, 14 | defaultSize, 15 | resizeCoordinates, 16 | stencilConstraints, 17 | transformImage, 18 | } from 'advanced-cropper/showcase/mobile'; 19 | import { PublicNavigationProps } from './Navigation'; 20 | import { CropperWrapper } from './CropperWrapper'; 21 | 22 | export interface CropperProps 23 | extends Omit< 24 | DefaultCropperProps, 25 | 'transitions' | 'priority' | 'imageRestriction' | 'stencilSize' | 'stencilConstraints' | 'transformImage' 26 | > { 27 | spinnerClassName?: string; 28 | resizeImage?: boolean | Omit; 29 | navigation?: boolean; 30 | navigationProps?: PublicNavigationProps; 31 | imageRestriction?: ImageRestriction.none | ImageRestriction.stencil; 32 | } 33 | 34 | export const Cropper = forwardRef((props: CropperProps, ref) => { 35 | const { 36 | className, 37 | spinnerClassName, 38 | navigation = true, 39 | stencilProps = {}, 40 | navigationProps = {}, 41 | wrapperComponent, 42 | imageRestriction = ImageRestriction.stencil, 43 | ...cropperProps 44 | } = props; 45 | 46 | const cropperRef = useRef(null); 47 | 48 | const WrapperComponent = wrapperComponent || CropperWrapper; 49 | 50 | return ( 51 | 74 | ); 75 | }); 76 | 77 | Cropper.displayName = 'Cropper'; 78 | -------------------------------------------------------------------------------- /src/components/CropperWrapper.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/constants'; 2 | @import '../styles/mixins'; 3 | 4 | .rmc-cropper-wrapper { 5 | padding: 40px 10px; 6 | overflow: hidden; 7 | background: black; 8 | max-height: 100%; 9 | &--with-navigation { 10 | padding-bottom: 80px; 11 | } 12 | 13 | &__content { 14 | position: relative; 15 | } 16 | 17 | &__fade { 18 | flex-grow: 1; 19 | min-height: 0; 20 | } 21 | 22 | &__navigation { 23 | position: absolute; 24 | bottom: 0; 25 | max-width: 450px; 26 | width: 100%; 27 | left: 50%; 28 | transform: translateX(-50%); 29 | } 30 | 31 | &__spinner { 32 | @include hideable(); 33 | stroke: $rmc-spinner-color; 34 | position: absolute; 35 | left: 50%; 36 | top: 50%; 37 | transform: translate(-50%, -50%); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/CropperWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, FC, useEffect, useRef } from 'react'; 2 | import { CropperRef, CropperFade } from 'react-advanced-cropper'; 3 | import cn from 'classnames'; 4 | import { Spinner } from '../icons/Spinner'; 5 | import { Navigation, NavigationRef, PublicNavigationProps } from './Navigation'; 6 | 7 | export interface CropperWrapperProps { 8 | cropper: CropperRef; 9 | loading: boolean; 10 | loaded: boolean; 11 | className?: string; 12 | style?: CSSProperties; 13 | spinnerClassName?: string; 14 | navigation?: boolean; 15 | navigationProps?: PublicNavigationProps; 16 | } 17 | 18 | export const CropperWrapper: FC> = ({ 19 | cropper, 20 | children, 21 | loaded, 22 | loading, 23 | className, 24 | spinnerClassName, 25 | navigation, 26 | navigationProps = {}, 27 | }) => { 28 | const navigationRef = useRef(null); 29 | 30 | const state = cropper.getState(); 31 | 32 | const transitions = cropper.getTransitions(); 33 | 34 | const { rotate } = cropper.getTransforms(); 35 | 36 | useEffect(() => { 37 | navigationRef.current?.refresh(); 38 | }, [state?.boundary.width, state?.boundary.height]); 39 | 40 | return ( 41 | 42 | 43 | {children} 44 | {navigation && ( 45 | 59 | )} 60 | 61 | 68 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /src/components/Navigation.scss: -------------------------------------------------------------------------------- 1 | .rmc-navigation { 2 | display: flex; 3 | align-items: center; 4 | padding: 20px 15px; 5 | &__rotator { 6 | width: 100%; 7 | margin-left: 10px; 8 | margin-right: 10px; 9 | } 10 | &__button { 11 | cursor: pointer; 12 | width: 24px; 13 | height: 24px; 14 | display: flex; 15 | align-items: center; 16 | flex-shrink: 0; 17 | margin: 0 5px; 18 | background: none; 19 | border: none; 20 | outline: none; 21 | transition: transform 0.5s; 22 | padding: 0; 23 | &:hover, &:focus { 24 | transform: scale(1.1); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'; 2 | import { ImmediatelyOptions, InteractionOptions, TransitionOptions } from 'advanced-cropper'; 3 | import cn from 'classnames'; 4 | import { FlipHorizontalIcon } from '../icons/FlipHorizontalIcon'; 5 | import { RotateRightIcon } from '../icons/RotateRightIcon'; 6 | import { RotateLeftIcon } from '../icons/RotateLeftIcon'; 7 | import { FlipVerticalIcon } from '../icons/FlipVerticalIcon'; 8 | import { RotateComponent, RotateComponentRef } from './RotateComponent'; 9 | 10 | export interface PublicNavigationProps { 11 | className?: string; 12 | buttonClassName?: string; 13 | rotateComponentClassName?: string; 14 | barClassName?: string; 15 | highlightedBarClassName?: string; 16 | activeBarClassName?: string; 17 | zeroBarClassName?: string; 18 | valueBarClassName?: string; 19 | } 20 | 21 | interface NavigationProps extends PublicNavigationProps { 22 | value: number; 23 | onRotate?: (angle: number, options?: TransitionOptions & InteractionOptions & ImmediatelyOptions) => void; 24 | onRotateEnd?: () => void; 25 | onFlip?: ( 26 | horizontal: boolean, 27 | vertical?: boolean, 28 | options?: TransitionOptions & InteractionOptions & ImmediatelyOptions, 29 | ) => void; 30 | className?: string; 31 | disabled?: unknown; 32 | } 33 | 34 | export interface NavigationRef { 35 | refresh: () => void; 36 | } 37 | 38 | export const Navigation = forwardRef( 39 | ( 40 | { 41 | className, 42 | buttonClassName, 43 | rotateComponentClassName, 44 | barClassName, 45 | highlightedBarClassName, 46 | zeroBarClassName, 47 | valueBarClassName, 48 | disabled, 49 | value, 50 | onRotate, 51 | onRotateEnd, 52 | onFlip, 53 | }: NavigationProps, 54 | ref, 55 | ) => { 56 | const [quarter, setQuarter] = useState(0); 57 | const [adjustmentAngle, setAdjustmentAngle] = useState(0); 58 | const rotateComponentRef = useRef(null); 59 | 60 | useLayoutEffect(() => { 61 | const absRotate = Math.abs(value); 62 | 63 | let rotate; 64 | if (absRotate % 90 > 45) { 65 | rotate = (absRotate - (absRotate % 90) + 90) / 90; 66 | } else if (absRotate % 90 < 45) { 67 | rotate = (absRotate - (absRotate % 90)) / 90; 68 | } else { 69 | rotate = quarter; 70 | } 71 | rotate = Math.sign(rotate) * rotate; 72 | 73 | if (rotate !== quarter) { 74 | setQuarter(rotate); 75 | } 76 | setAdjustmentAngle(Math.sign(value) * (Math.abs(value) - Math.abs(rotate) * 90)); 77 | // eslint-disable-next-line react-hooks/exhaustive-deps 78 | }, [value]); 79 | 80 | useImperativeHandle(ref, () => { 81 | return { 82 | refresh() { 83 | if (rotateComponentRef.current) { 84 | rotateComponentRef.current.refresh(); 85 | } 86 | }, 87 | }; 88 | }); 89 | 90 | const rotateTo = (angle: number) => { 91 | if (onRotate && !disabled) { 92 | onRotate(angle, { 93 | transitions: false, 94 | interaction: true, 95 | immediately: true, 96 | }); 97 | } 98 | }; 99 | 100 | const rotateLeft = () => { 101 | if (onRotate && !disabled) { 102 | if (adjustmentAngle > 0) { 103 | onRotate(-adjustmentAngle); 104 | } else if (adjustmentAngle < 0) { 105 | onRotate(-90 - adjustmentAngle); 106 | } else { 107 | onRotate(-90); 108 | } 109 | } 110 | }; 111 | 112 | const rotateRight = () => { 113 | if (onRotate && !disabled) { 114 | if (adjustmentAngle > 0) { 115 | onRotate(90 - adjustmentAngle); 116 | } else if (adjustmentAngle < 0) { 117 | onRotate(-adjustmentAngle); 118 | } else { 119 | onRotate(90); 120 | } 121 | } 122 | }; 123 | 124 | const flipHorizontal = () => { 125 | if (onFlip && !disabled) { 126 | onFlip(true); 127 | } 128 | }; 129 | 130 | const flipVertical = () => { 131 | if (onFlip && !disabled) { 132 | onFlip(false, true); 133 | } 134 | }; 135 | 136 | return ( 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | ); 165 | }, 166 | ); 167 | 168 | Navigation.displayName = 'Navigation'; 169 | -------------------------------------------------------------------------------- /src/components/RotateComponent.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/constants.scss'; 2 | 3 | .rmc-rotate-component { 4 | &__bars { 5 | cursor: grab; 6 | width: 100%; 7 | display: flex; 8 | min-width: 0; 9 | position: relative; 10 | height: 15px; 11 | &--dragging { 12 | cursor: grabbing !important; 13 | } 14 | } 15 | &__bar { 16 | position: absolute; 17 | left:0; 18 | height: 15px; 19 | width: 3px; 20 | flex-shrink: 0; 21 | top: 50%; 22 | transform: translateY(-50%); 23 | background: $rmc-bar-color; 24 | &--highlighted { 25 | background-color: $rmc-highlighted-bar-color; 26 | } 27 | &--zero { 28 | height: 20px; 29 | } 30 | } 31 | &__value { 32 | position: absolute; 33 | left: 50%; 34 | top: 50%; 35 | transform: translate(-50%, -50%); 36 | width: 3px; 37 | background-color: $rmc-accent-color; 38 | color: $rmc-accent-color; 39 | height: 25px; 40 | border-radius: 2px; 41 | } 42 | &__value-number { 43 | position: absolute; 44 | top: -20px; 45 | color: inherit; 46 | left: 50%; 47 | transform: translateX(-50%); 48 | font-size:12px; 49 | } 50 | } 51 | 52 | body { 53 | &.dragging { 54 | cursor: grabbing !important; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/RotateComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; 2 | import { DraggableArea, MoveDirections } from 'react-advanced-cropper'; 3 | import cn from 'classnames'; 4 | 5 | interface RotateComponentProps { 6 | from: number; 7 | to: number; 8 | value: number; 9 | step?: number; 10 | onChange?: (value: number) => void; 11 | onBlur?: () => void; 12 | className?: string; 13 | barsClassName?: string; 14 | barClassName?: string; 15 | highlightedBarClassName?: string; 16 | valueBarClassName?: string; 17 | zeroBarClassName?: string; 18 | count?: number; 19 | thickness?: number; 20 | density?: number; 21 | } 22 | 23 | export interface RotateComponentRef { 24 | refresh: () => void; 25 | } 26 | 27 | function range(from: number, to: number, step = 1): number[] { 28 | let index = -1; 29 | let length = Math.max(Math.ceil((to - from) / (step || 1)), 0); 30 | 31 | const result = new Array(length); 32 | 33 | while (length--) { 34 | result[++index] = from; 35 | from += step; 36 | } 37 | return result; 38 | } 39 | 40 | export const RotateComponent = forwardRef( 41 | ( 42 | { 43 | from, 44 | to, 45 | value, 46 | step = 2.5, 47 | thickness = 2, 48 | onBlur, 49 | onChange, 50 | className, 51 | valueBarClassName, 52 | barsClassName, 53 | barClassName, 54 | highlightedBarClassName, 55 | zeroBarClassName, 56 | density = 10, 57 | }: RotateComponentProps, 58 | ref, 59 | ) => { 60 | const barsRef = useRef(null); 61 | 62 | const [dragging, setDragging] = useState(false); 63 | 64 | const [items, setItems] = useState([]); 65 | 66 | const recalculate = () => { 67 | if (barsRef.current) { 68 | const width = barsRef.current.clientWidth; 69 | 70 | const count = width / density; 71 | 72 | const neededLeftBarsCount = Math.max(0, Math.floor(count / 2) - Math.round((value - from) / step)); 73 | 74 | const neededRightBarsCount = Math.max(0, Math.floor(count / 2) - Math.round((to - value) / step)); 75 | 76 | const values = [ 77 | ...range(from - neededLeftBarsCount * step, from, step), 78 | ...range(from, to + step, step), 79 | ...range(to + step, to + step + neededRightBarsCount * step, step), 80 | ]; 81 | 82 | const radius = Math.abs(Math.ceil(count / 2) * step); 83 | 84 | setItems( 85 | values.map((barValue) => { 86 | const sign = Math.sign(barValue - value); 87 | 88 | // Opacity 89 | let translate; 90 | if (Math.abs(barValue - value) / step <= Math.ceil(count / 2)) { 91 | const multiplier = 92 | Math.sqrt(Math.pow(radius, 2) - Math.pow(value + sign * radius - barValue, 2)) / radius; 93 | translate = width / 2 + sign * (width / 2) * Math.pow(multiplier, 2.5); 94 | } else { 95 | translate = width / 2 + (sign * width) / 2; 96 | } 97 | 98 | // Translate 99 | let opacity = 0; 100 | if (count > 0 && Math.abs(barValue - value) / step <= Math.ceil(count / 2)) { 101 | opacity = Math.pow( 102 | Math.sqrt(Math.pow(radius, 2) - Math.pow(value - barValue, 2)) / radius, 103 | 4, 104 | ); 105 | } 106 | 107 | return { 108 | value: barValue, 109 | highlighted: 110 | (value < 0 && barValue >= value && barValue <= 0) || 111 | (value > 0 && barValue <= value && barValue >= 0), 112 | zero: barValue === 0, 113 | opacity, 114 | translate: translate - thickness / 2, 115 | }; 116 | }), 117 | ); 118 | } 119 | }; 120 | 121 | useEffect(() => { 122 | recalculate(); 123 | // eslint-disable-next-line react-hooks/exhaustive-deps 124 | }, [density, thickness, from, to, value, step]); 125 | 126 | useImperativeHandle(ref, () => { 127 | return { 128 | refresh: recalculate, 129 | }; 130 | }); 131 | 132 | const onMove = (directions: MoveDirections) => { 133 | if (barsRef.current) { 134 | const width = barsRef.current.clientWidth; 135 | const count = width / density; 136 | const shift = -(directions.left / barsRef.current.clientWidth) * count * step; 137 | if (onChange) { 138 | if (value + shift > to) { 139 | onChange(to - value); 140 | } else if (value + shift < from) { 141 | onChange(from - value); 142 | } else { 143 | onChange(shift); 144 | } 145 | } 146 | } 147 | }; 148 | 149 | const onMoveEnd = () => { 150 | document.body.classList.remove('dragging'); 151 | setDragging(false); 152 | onBlur?.(); 153 | }; 154 | 155 | const onMoveStart = () => { 156 | document.body.classList.add('dragging'); 157 | setDragging(true); 158 | }; 159 | 160 | return ( 161 | 162 | 163 | 171 | {items.map((bar) => ( 172 | 188 | ))} 189 | 190 | {value.toFixed(1)}° 191 | 192 | 193 | 194 | 195 | ); 196 | }, 197 | ); 198 | 199 | RotateComponent.displayName = 'RotateComponent'; 200 | -------------------------------------------------------------------------------- /src/icons/FlipHorizontalIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | interface Props { 4 | className?: string; 5 | } 6 | 7 | export const FlipHorizontalIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/icons/FlipVerticalIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | interface Props { 4 | className?: string; 5 | } 6 | 7 | export const FlipVerticalIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 14 | 15 | 16 | 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/icons/RotateLeftIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | interface Props { 4 | className?: string; 5 | } 6 | 7 | export const RotateLeftIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/icons/RotateRightIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | interface Props { 4 | className?: string; 5 | } 6 | 7 | export const RotateRightIcon: FC = ({ className }) => { 8 | return ( 9 | 10 | 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/icons/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | interface Props { 4 | className?: string; 5 | } 6 | 7 | export const Spinner: FC = ({ className }) => { 8 | return ( 9 | 16 | 17 | 18 | 19 | 20 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { CropperRef } from 'react-advanced-cropper'; 2 | export { CircleStencil, RectangleStencil } from 'react-advanced-cropper'; 3 | export { Navigation } from './components/Navigation'; 4 | export { RotateComponent } from './components/RotateComponent'; 5 | export * from './components/Cropper'; 6 | -------------------------------------------------------------------------------- /src/service/utils.ts: -------------------------------------------------------------------------------- 1 | export function range(from: number, to: number, step = 1): number[] { 2 | let index = -1; 3 | let length = Math.max(Math.ceil((to - from) / (step || 1)), 0); 4 | const result = new Array(length); 5 | 6 | while (length--) { 7 | result[++index] = from; 8 | from += step; 9 | } 10 | return result; 11 | } 12 | -------------------------------------------------------------------------------- /src/style.basic.scss: -------------------------------------------------------------------------------- 1 | @import './styles/constants'; 2 | @import './components/Cropper'; 3 | @import './components/CropperWrapper'; 4 | @import './components/Navigation'; 5 | @import './components/RotateComponent'; 6 | -------------------------------------------------------------------------------- /src/style.scss: -------------------------------------------------------------------------------- 1 | @import 'react-advanced-cropper/dist/style'; 2 | @import 'react-advanced-cropper/dist/themes/corners'; 3 | @import 'style.basic'; 4 | -------------------------------------------------------------------------------- /src/styles/constants.scss: -------------------------------------------------------------------------------- 1 | $rmc-main-color: #61DAFB !default; 2 | $rmc-accent-color: currentColor !default; 3 | $rmc-spinner-color: $rmc-accent-color !default; 4 | $rmc-bar-color: white !default; 5 | $rmc-highlighted-bar-color: $rmc-accent-color !default; 6 | -------------------------------------------------------------------------------- /src/styles/mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin hideable() { 2 | visibility: hidden; 3 | opacity: 0; 4 | transition: 0.5s; 5 | transition-delay: 0s; 6 | &--visible { 7 | transition-delay: 0.5s; 8 | opacity: 1; 9 | visibility: visible; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "es2015" 8 | ], 9 | "declarationDir": "", 10 | "declaration": true, 11 | "noEmitHelpers": true, 12 | "importHelpers": true, 13 | "allowJs": true, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "isolatedModules": true, 18 | "jsx": "react", 19 | "module": "esnext", 20 | "moduleResolution": "node", 21 | "noEmit": true, 22 | "resolveJsonModule": true, 23 | "downlevelIteration": true, 24 | "skipLibCheck": true 25 | }, 26 | "include": [ 27 | "src" 28 | ] 29 | } 30 | --------------------------------------------------------------------------------