├── .gitattributes ├── test ├── assets │ └── nyc.jpg ├── uppy.js ├── index.test.js └── server.js ├── webpack.config.js ├── jest.config.js ├── .browserlistrc ├── jest-puppeteer.config.js ├── babel.config.js ├── .gitignore ├── LICENSE ├── README.md ├── package.json └── src └── index.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /test/assets/nyc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arturi/uppy-plugin-image-compressor/HEAD/test/assets/nyc.jpg -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: './test/uppy.js', 3 | output: { 4 | filename: './test/uppy.min.js', 5 | library: 'foo' 6 | }, 7 | mode: 'production' 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "jest-puppeteer", 3 | globals: { 4 | PATH: "http://localhost:4444" 5 | }, 6 | testMatch: [ 7 | "**/test/**/*.test.js" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.browserlistrc: -------------------------------------------------------------------------------- 1 | IE 10 2 | last 2 Safari versions 3 | last 2 Chrome versions 4 | last 2 ChromeAndroid versions 5 | last 2 Firefox versions 6 | last 2 FirefoxAndroid versions 7 | last 2 Edge versions 8 | iOS 11.2 9 | -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | server: { 3 | command: 'npm run test:serve', 4 | port: 4444, 5 | launchTimeout: 10000 6 | } 7 | // launch: { 8 | // dumpio: true, 9 | // headless: false, 10 | // slowMo : 50 11 | // } 12 | } 13 | -------------------------------------------------------------------------------- /test/uppy.js: -------------------------------------------------------------------------------- 1 | const Core = require('@uppy/core') 2 | const FileInput = require('@uppy/file-input') 3 | const ImageCompressor = require('../lib/index.js') 4 | 5 | const core = new Core({ 6 | debug: true 7 | }) 8 | core.use(FileInput, { 9 | target: 'body' 10 | }) 11 | core.use(ImageCompressor, { 12 | quality: 0.6 13 | }) 14 | 15 | window.uppy = core 16 | 17 | export default uppy 18 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => { 2 | const targets = {} 3 | if (api.env('test')) { 4 | targets.node = 'current' 5 | } 6 | 7 | return { 8 | presets: [ 9 | ['@babel/preset-env', { 10 | modules: false, 11 | loose: true, 12 | targets 13 | }] 14 | ], 15 | plugins: [ 16 | '@babel/plugin-transform-object-assign', 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | .DS_Store 26 | 27 | 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Artur Paikin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Uppy Image Compressor 2 | 3 | Uppy logo: a superman puppy in a pink suit 4 | 5 | ImageCompressor is an [Uppy](https://uppy.io) file uploader plugin, that compresses images before upload, saving bandwidth. 6 | 7 | ImageCompressor uses [Compressor.js](https://github.com/fengyuanchen/compressorjs), and the compression is lossy. From Compressor.js readme: 8 | 9 | > JavaScript image compressor. Uses the Browser's native canvas.toBlob API to do the compression work, which means it is lossy compression. General use this to precompress a client image file before upload it. 10 | 11 | :warning: This is not an official Uppy plugin, so no support is offered for it. Please use at your own risk. 12 | 13 | Uppy is being developed by the folks at [Transloadit](https://transloadit.com), a versatile file encoding service. 14 | 15 | ## Example 16 | 17 | ```js 18 | const Uppy = require('@uppy/core') 19 | const ImageCompressor = require('uppy-plugin-image-compressor') 20 | 21 | const uppy = Uppy() 22 | uppy.use(ImageCompressor, { 23 | // Options from Compressor.js https://github.com/fengyuanchen/compressorjs#options, just don’t set `success` or `error` 24 | }) 25 | ``` 26 | 27 | ## Installation 28 | 29 | ```bash 30 | $ npm install uppy-plugin-image-compressor --save 31 | ``` 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uppy-plugin-image-compressor", 3 | "version": "1.1.0", 4 | "description": "Compresses images added to Uppy before upload, using Compressor.js package (lossy compression)", 5 | "repository": "https://github.com/arturi/uppy-plugin-image-compressor", 6 | "main": "lib/index.js", 7 | "scripts": { 8 | "build": "babel src/index.js -o lib/index.js", 9 | "test": "jest", 10 | "test:serve": "node test/server.js", 11 | "prepublish": "npm run build" 12 | }, 13 | "keywords": [ 14 | "uppy", 15 | "uppy-plugin", 16 | "file uploader" 17 | ], 18 | "author": "Artur Paikin", 19 | "license": "MIT", 20 | "jest": { 21 | "preset": "jest-puppeteer", 22 | "globals": { 23 | "PATH": "http://localhost:4444" 24 | }, 25 | "testMatch": [ 26 | "**/test/**/*.test.js" 27 | ] 28 | }, 29 | "dependencies": { 30 | "@uppy/core": "^1.6.0", 31 | "@uppy/utils": "^2.1.0", 32 | "compressorjs": "^1.0.5" 33 | }, 34 | "devDependencies": { 35 | "@babel/cli": "^7.6.4", 36 | "@babel/core": "^7.6.4", 37 | "@babel/plugin-transform-object-assign": "^7.2.0", 38 | "@babel/preset-env": "^7.6.3", 39 | "@uppy/dashboard": "^1.5.0", 40 | "@uppy/file-input": "^1.4.0", 41 | "express": "^4.17.1", 42 | "jest": "^24.9.0", 43 | "jest-puppeteer": "^4.3.0", 44 | "karmatic": "^1.4.0", 45 | "puppeteer": "^2.0.0", 46 | "webpack": "^4.41.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const nycJpgPath = path.join(__dirname, 'assets/nyc.jpg') 3 | 4 | beforeEach(async () => { 5 | jest.setTimeout(20 * 1000) 6 | await page.goto(PATH, { waitUntil: 'load' }) 7 | }) 8 | 9 | describe('Image Compressor', () => { 10 | it('should compress an image before upload, its size should become smaller', async () => { 11 | const [fileChooser] = await Promise.all([ 12 | page.waitForFileChooser(), 13 | page.click('.uppy-FileInput-btn') // some button that triggers file selection 14 | ]) 15 | await fileChooser.accept([nycJpgPath]) 16 | 17 | const sizes = await page.evaluate(() => { 18 | return new Promise((resolve) => { 19 | const sizeBefore = window.uppy.getFiles()[0].data.size 20 | return window.uppy.upload().then(() => { 21 | const sizeAfter = window.uppy.getFiles()[0].data.size 22 | return resolve({ 23 | before: sizeBefore, 24 | after: sizeAfter 25 | }) 26 | }) 27 | }) 28 | }) 29 | 30 | console.log(sizes) 31 | 32 | expect(sizes.after).toBeLessThan(sizes.before) 33 | expect(sizes.before).toBe(33981) 34 | expect(sizes.after).toBe(12174) 35 | }) 36 | }) 37 | 38 | // if we want to load an image as blob from a url 39 | // did this at first, went with `fileChooser` instead 40 | 41 | // window.loadImageAsBlob = (url, done) => { 42 | // const xhr = new XMLHttpRequest() 43 | 44 | // xhr.onload = () => { 45 | // const blob = xhr.response 46 | 47 | // blob.name = url.replace(/^.*?(\w+\.\w+)$/, '$1') 48 | // done(blob) 49 | // }; 50 | // xhr.open('GET', url) 51 | // xhr.responseType = 'blob' 52 | // xhr.send() 53 | // } 54 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const webpack = require('webpack') 3 | const middleware = require('webpack-dev-middleware') 4 | const path = require('path') 5 | 6 | const compiler = webpack(require('../webpack.config.js')) 7 | 8 | // Turns input into an array if not one already 9 | function normalizeArray (arr) { 10 | return Array.isArray(arr) ? arr : [arr] 11 | } 12 | 13 | // Gets all the Javascript paths that Webpack has compiled, across chunks 14 | function getAllJsPaths (webpackJson) { 15 | const { assetsByChunkName } = webpackJson 16 | return Object.values(assetsByChunkName).reduce((paths, assets) => { 17 | for (let asset of normalizeArray(assets)) { 18 | if (asset != null && asset.endsWith('.js')) { 19 | paths.push(asset) 20 | } 21 | } 22 | return paths 23 | }, []) 24 | } 25 | 26 | // Optionally, just get the Javascript paths from specific chunks 27 | function getJsPathsFromChunks (webpackJson, chunkNames) { 28 | const { assetsByChunkName } = webpackJson 29 | chunkNames = normalizeArray(chunkNames) 30 | return chunkNames.reduce((paths, name) => { 31 | if (assetsByChunkName[name] != null) { 32 | for (let asset of normalizeArray(assetsByChunkName[name])) { 33 | if (asset != null && asset.endsWith('.js')) { 34 | paths.push(asset) 35 | } 36 | } 37 | } 38 | return paths 39 | }, []) 40 | } 41 | 42 | let port = 4444 43 | const index = Math.max(process.argv.indexOf('--port'), process.argv.indexOf('-p')) 44 | if (index !== -1) { 45 | port = +process.argv[index + 1] || port 46 | } 47 | 48 | const app = express() 49 | app.use(middleware(compiler, { serverSideRender: true })) 50 | app.use(express.static(path.join(__dirname, 'assets'))) 51 | app.use('/', (req, res) => { 52 | const webpackJson = res.locals.webpackStats.toJson() 53 | const paths = getAllJsPaths(webpackJson) 54 | res.send( 55 | ` 56 | 57 | 58 | Test 59 | 60 | 61 |
62 | ${paths.map((path) => ``).join('')} 63 | 64 | ` 65 | ) 66 | }) 67 | app.listen(port, () => { 68 | console.log(`Server started at http://localhost:${port}/`) 69 | }) 70 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const { Plugin } = require('@uppy/core') 2 | const Translator = require('@uppy/utils/lib/Translator') 3 | const Compressor = require('compressorjs/dist/compressor.common.js') 4 | 5 | class UppyImageCompressor extends Plugin { 6 | constructor (uppy, opts) { 7 | super(uppy, opts) 8 | this.id = this.opts.id || 'ImageCompressor' 9 | this.type = 'modifier' 10 | 11 | this.defaultLocale = { 12 | strings: { 13 | compressingImages: 'Compressing images...' 14 | } 15 | } 16 | 17 | const defaultOptions = { 18 | quality: 0.6 19 | } 20 | 21 | this.opts = Object.assign({}, defaultOptions, opts) 22 | 23 | // we use those internally in `this.compress`, so they 24 | // should not be overriden 25 | delete this.opts.success 26 | delete this.opts.error 27 | 28 | this.i18nInit() 29 | 30 | this.prepareUpload = this.prepareUpload.bind(this) 31 | this.compress = this.compress.bind(this) 32 | } 33 | 34 | setOptions (newOpts) { 35 | super.setOptions(newOpts) 36 | this.i18nInit() 37 | } 38 | 39 | i18nInit () { 40 | this.translator = new Translator([this.defaultLocale, this.uppy.locale, this.opts.locale]) 41 | this.i18n = this.translator.translate.bind(this.translator) 42 | this.setPluginState() // so that UI re-renders and we see the updated locale 43 | } 44 | 45 | compress (blob) { 46 | return new Promise((resolve, reject) => { 47 | new Compressor(blob, Object.assign( 48 | {}, 49 | this.opts, 50 | { 51 | success: (result) => { 52 | return resolve(result) 53 | }, 54 | error: (err) => { 55 | return reject(err) 56 | } 57 | } 58 | )) 59 | }) 60 | } 61 | 62 | prepareUpload (fileIDs) { 63 | const promises = fileIDs.map((fileID) => { 64 | const file = this.uppy.getFile(fileID) 65 | this.uppy.emit('preprocess-progress', file, { 66 | mode: 'indeterminate', 67 | message: this.i18n('compressingImages') 68 | }) 69 | 70 | if (file.type.split('/')[0] !== 'image') { 71 | return 72 | } 73 | 74 | return this.compress(file.data).then((compressedBlob) => { 75 | this.uppy.log(`[Image Compressor] Image ${file.id} size before/after compression: ${file.data.size} / ${compressedBlob.size}`) 76 | this.uppy.setFileState(fileID, { data: compressedBlob }) 77 | }).catch((err) => { 78 | this.uppy.log(`[Image Compressor] Failed to compress ${file.id}:`, 'warning') 79 | this.uppy.log(err, 'warning') 80 | }) 81 | }) 82 | 83 | const emitPreprocessCompleteForAll = () => { 84 | fileIDs.forEach((fileID) => { 85 | const file = this.uppy.getFile(fileID) 86 | this.uppy.emit('preprocess-complete', file) 87 | }) 88 | } 89 | 90 | // Why emit `preprocess-complete` for all files at once, instead of 91 | // above when each is processed? 92 | // Because it leads to StatusBar showing a weird “upload 6 files” button, 93 | // while waiting for all the files to complete pre-processing. 94 | return Promise.all(promises) 95 | .then(emitPreprocessCompleteForAll) 96 | } 97 | 98 | install () { 99 | this.uppy.addPreProcessor(this.prepareUpload) 100 | } 101 | 102 | uninstall () { 103 | this.uppy.removePreProcessor(this.prepareUpload) 104 | } 105 | } 106 | 107 | module.exports = UppyImageCompressor 108 | --------------------------------------------------------------------------------