├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc ├── composer.json ├── dist ├── css │ └── field.css └── js │ └── field.js ├── fonts └── vendor │ └── element-ui │ └── lib │ └── theme-chalk │ ├── element-icons.ttf │ └── element-icons.woff ├── mix-manifest.json ├── package.json ├── readme.md ├── resources ├── js │ ├── components │ │ ├── DetailField.vue │ │ ├── FormField.vue │ │ ├── IndexField.vue │ │ ├── PictureCropper.vue │ │ ├── PicturePicker.vue │ │ └── PicturePickerFile.vue │ ├── field.js │ └── utils │ │ └── image.js └── sass │ └── field.scss ├── src ├── FieldServiceProvider.php └── ImageCropper.php ├── webpack.mix.js └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [beliolfa] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor 3 | /node_modules 4 | package-lock.json 5 | composer.phar 6 | composer.lock 7 | phpunit.xml 8 | .phpunit.result.cache 9 | .DS_Store 10 | Thumbs.db 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "r64/nova-image-cropper", 3 | "description": "A Laravel Nova field.", 4 | "keywords": [ 5 | "laravel", 6 | "nova" 7 | ], 8 | "license": "MIT", 9 | "require": { 10 | "php": ">=7.1.0" 11 | }, 12 | "autoload": { 13 | "psr-4": { 14 | "R64\\NovaImageCropper\\": "src/" 15 | } 16 | }, 17 | "extra": { 18 | "laravel": { 19 | "providers": [ 20 | "R64\\NovaImageCropper\\FieldServiceProvider" 21 | ] 22 | } 23 | }, 24 | "config": { 25 | "sort-packages": true 26 | }, 27 | "minimum-stability": "dev", 28 | "prefer-stable": true 29 | } 30 | -------------------------------------------------------------------------------- /dist/css/field.css: -------------------------------------------------------------------------------- 1 | .el-upload,.el-upload-dragger{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;width:100%!important;height:100%!important}.el-upload-dragger{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.picker-wrapper{background-color:#fff;border:1px solid #c4cdd5;text-align:center}.picker-file{width:100%;height:272px;padding:10px}.avatar .cropper-face,.avatar .cropper-view-box{border-radius:50%} -------------------------------------------------------------------------------- /fonts/vendor/element-ui/lib/theme-chalk/element-icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/64robots/nova-image-cropper/efba771c7a80327b24e1f925602aa564ea0a5df5/fonts/vendor/element-ui/lib/theme-chalk/element-icons.ttf -------------------------------------------------------------------------------- /fonts/vendor/element-ui/lib/theme-chalk/element-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/64robots/nova-image-cropper/efba771c7a80327b24e1f925602aa564ea0a5df5/fonts/vendor/element-ui/lib/theme-chalk/element-icons.woff -------------------------------------------------------------------------------- /mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/dist/js/field.js": "/dist/js/field.js", 3 | "/dist/css/field.css": "/dist/css/field.css" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 6 | "watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 7 | "watch-poll": "npm run watch -- --watch-poll", 8 | "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", 9 | "prod": "npm run production", 10 | "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" 11 | }, 12 | "devDependencies": { 13 | "babel-plugin-component": "^1.1.1", 14 | "cropperjs": "^1.4.3", 15 | "cross-env": "^5.0.0", 16 | "element-ui": "^2.4.6", 17 | "laravel-mix": "^5.0.4", 18 | "resolve-url-loader": "^3.1.0", 19 | "sass": "^1.26.7", 20 | "sass-loader": "^8.0.2", 21 | "laravel-nova": "^1.0", 22 | "vue": "^2.6.11", 23 | "vue-template-compiler": "^2.6.11" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Image Field with built-in cropper for Laravel Nova 2 | 3 | This field extends Image Field adding a handy cropper to manipulate images. Can be configurable in the same way as a [File field in Nova](https://nova.laravel.com/docs/1.0/resources/file-fields.html). 4 | 5 | ### Demo 6 | 7 |  8 | 9 | ### Install 10 | 11 | Run this command into your nova project: 12 | `composer require r64/nova-image-cropper` 13 | 14 | ### Add it to your Nova Resource: 15 | 16 | ```php 17 | use R64\NovaImageCropper\ImageCropper; 18 | 19 | ImageCropper::make('Photo'), 20 | ``` 21 | 22 | ### Update form 23 | 24 | In order to edit the existing image saved in the model, ImageCroper uses the preview method to return a base64 encoded image. You can either use the default implementation or override it as long as you return a base64 image. 25 | 26 | ```php 27 | use R64\NovaImageCropper\ImageCropper; 28 | 29 | ImageCropper::make('Photo') 30 | ->preview(function () { 31 | if (!$this->value) return null; 32 | 33 | $url = Storage::disk($this->disk)->url($this->value); 34 | $filetype = pathinfo($url)['extension']; 35 | return 'data:image/' . $filetype . ';base64,' . base64_encode(file_get_contents($url)); 36 | }); 37 | ``` 38 | 39 | ### Options 40 | 41 | #### Avatar mode 42 | 43 | You can add a rounded mask to the preview and the cropper 44 | 45 | ```php 46 | ImageCropper::make('Photo')->avatar() 47 | ``` 48 | 49 | #### Custom aspect ratio 50 | 51 | Define the fixed aspect ratio of the crop box. 52 | 53 | - Type: Number 54 | - Default: NaN 55 | 56 | ```php 57 | ImageCropper::make('Photo')->aspectRatio(16/9) 58 | ``` 59 | 60 | For free ratio use: 61 | 62 | ```php 63 | ImageCropper::make('Photo')->aspectRatio(0) 64 | ``` 65 | 66 | ### Localization 67 | 68 | Set your translations in the corresponding xx.json file located in `/resources/lang/vendor/nova` 69 | 70 | ```php 71 | ... 72 | 73 | "Edit Image": "Editar Imagen", 74 | "Cancel Crop": "Cancelar Recorte", 75 | "Change Image": "Cambiar Imagen", 76 | "Done": "Hecho", 77 | "Click here or drop the file to upload": "Click aquí o arrastra el archivo para comenzar la subida" 78 | ``` 79 | -------------------------------------------------------------------------------- /resources/js/components/DetailField.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 11 | 12 | 17 | 26 | -------------------------------------------------------------------------------- /resources/js/components/FormField.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | {{__('Edit Image')}} 21 | {{__('Delete')}} 26 | 27 | 31 | 32 | 33 | {{ firstError }} 37 | 38 | 39 | 40 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 193 | 202 | -------------------------------------------------------------------------------- /resources/js/components/IndexField.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | {{ field.value || '—' }} 11 | 12 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /resources/js/components/PictureCropper.vue: -------------------------------------------------------------------------------- 1 | 2 | 6 | 11 | 12 | 13 | 103 | 113 | -------------------------------------------------------------------------------- /resources/js/components/PicturePicker.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 16 | {{__('Cancel Crop')}} 20 | {{__('Change Image')}} 24 | {{__('Done')}} 28 | 29 | 30 | 31 | 38 | 43 | 44 | 45 | 46 | 47 | 164 | 227 | -------------------------------------------------------------------------------- /resources/js/components/PicturePickerFile.vue: -------------------------------------------------------------------------------- 1 | 2 | 10 | {{__('Click here or drop the file to upload')}} 11 | 12 | 13 | 28 | 44 | -------------------------------------------------------------------------------- /resources/js/field.js: -------------------------------------------------------------------------------- 1 | Nova.booting((Vue, router) => { 2 | Vue.component('index-nova-image-cropper', require('./components/IndexField')); 3 | Vue.component('detail-nova-image-cropper', require('./components/DetailField')); 4 | Vue.component('form-nova-image-cropper', require('./components/FormField')); 5 | }) 6 | -------------------------------------------------------------------------------- /resources/js/utils/image.js: -------------------------------------------------------------------------------- 1 | export const calculateAspectRatioFit = ( 2 | srcWidth, 3 | srcHeight, 4 | maxWidth = 2000, 5 | maxHeight = 1000 6 | ) => { 7 | let ratio = 1; 8 | 9 | if (srcWidth > maxWidth || srcHeight > maxHeight) { 10 | ratio = Math.min(maxWidth / srcWidth, maxHeight / srcHeight); 11 | } 12 | 13 | return { width: srcWidth * ratio, height: srcHeight * ratio }; 14 | }; 15 | 16 | export const resizeImage = (image, type, cb) => { 17 | const newImage = new Image(); 18 | newImage.onload = () => { 19 | const { width, height } = calculateAspectRatioFit( 20 | newImage.width, 21 | newImage.height 22 | ); 23 | const canvas = document.createElement('canvas'); 24 | canvas.width = width; 25 | canvas.height = height; 26 | const ctx = canvas.getContext('2d'); 27 | ctx.drawImage(newImage, 0, 0, width, height); 28 | const dataUrl = canvas.toDataURL(type); 29 | canvas.toBlob(blob => { 30 | const file = new File([blob], 'uploaded_file.jpg', { 31 | type, 32 | lastModified: Date.now() 33 | }); 34 | const params = { dataUrl, width, height, file }; 35 | cb(params); 36 | }); 37 | }; 38 | newImage.src = image; 39 | }; 40 | 41 | export const UrlToBase64 = (url, onSuccess, onError) => { 42 | var img = new Image(); 43 | var canvas = document.createElement('CANVAS'); 44 | var ctx = canvas.getContext('2d'); 45 | img.crossOrigin = ''; 46 | 47 | img.onload = function() { 48 | canvas.height = img.height; 49 | canvas.width = img.width; 50 | ctx.drawImage(img, 0, 0); 51 | cb(canvas.toDataURL('image/png')); 52 | canvas = null; 53 | }; 54 | img.src = url; 55 | }; 56 | -------------------------------------------------------------------------------- /resources/sass/field.scss: -------------------------------------------------------------------------------- 1 | // Nova Tool CSS 2 | 3 | .el-upload, 4 | .el-upload-dragger { 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | width: 100% !important; 9 | height: 100% !important; 10 | } 11 | 12 | .el-upload-dragger { 13 | flex-direction: column; 14 | } 15 | .picker-wrapper { 16 | background-color: #ffffff; 17 | border: solid 1px #c4cdd5; 18 | text-align: center; 19 | } 20 | .picker-file { 21 | width: 100%; 22 | height: 272px; 23 | padding: 10px; 24 | } 25 | 26 | .avatar .cropper-view-box, 27 | .avatar .cropper-face { 28 | border-radius: 50%; 29 | } 30 | -------------------------------------------------------------------------------- /src/FieldServiceProvider.php: -------------------------------------------------------------------------------- 1 | preview(function () { 31 | if (!$this->value) { 32 | return null; 33 | } 34 | 35 | $url = Storage::disk($this->disk)->url($this->value); 36 | 37 | $path_info = pathinfo($url); 38 | 39 | $filetype = 'jpg'; 40 | 41 | if (array_key_exists('extension', $path_info)) { 42 | $filetype = $path_info['extension']; 43 | } 44 | 45 | try { 46 | $encoded_file = base64_encode(file_get_contents($url)); 47 | } catch (\Exception $e) { 48 | return ''; 49 | } 50 | 51 | return 'data:image/' . $filetype . ';base64,' . $encoded_file; 52 | }); 53 | } 54 | 55 | public function avatar() 56 | { 57 | return $this->withMeta(['isAvatar' => true]); 58 | } 59 | 60 | public function aspectRatio($ratio) 61 | { 62 | return $this->withMeta(['aspectRatio' => $ratio]); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix') 2 | 3 | mix 4 | .js('resources/js/field.js', 'dist/js') 5 | .sass('resources/sass/field.scss', 'dist/css') 6 | .setPublicPath('./') 7 | .webpackConfig({ 8 | resolve: { 9 | symlinks: false 10 | } 11 | }) 12 | .babelConfig({ 13 | plugins: [ 14 | [ 15 | 'component', 16 | { 17 | libraryName: 'element-ui', 18 | styleLibraryName: 'theme-chalk' 19 | } 20 | ] 21 | ] 22 | }) 23 | --------------------------------------------------------------------------------
{{ firstError }}
3 | 10 | {{ field.value || '—' }} 11 |