├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── dist ├── demo.min.js ├── quill.imageUploader.min.css └── quill.imageUploader.min.js ├── package-lock.json ├── package.json ├── src ├── blots │ └── image.js ├── demo.html ├── demo.js ├── dist.js ├── quill.imageUploader.css └── quill.imageUploader.js ├── static └── quill-example.gif └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "targets": { 7 | "browsers": ["last 3 versions", "safari >= 6"] 8 | }, 9 | "modules": false 10 | } 11 | ] 12 | ], 13 | "plugins": [] 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://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 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Noel O'Connell 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Quill ImageHandler Module 2 | 3 | A module for Quill rich text editor to allow images to be uploaded to a server instead of being base64 encoded. 4 | Adds a button to the toolbar for users to click, also handles drag,dropped and pasted images. 5 | 6 | ## Demo 7 | 8 | ![Image of Yaktocat](/static/quill-example.gif) 9 | 10 | ### Install 11 | 12 | Install with npm: 13 | 14 | ```bash 15 | npm install quill-image-uploader --save 16 | ``` 17 | 18 | ### Webpack/ES6 19 | 20 | ```javascript 21 | import Quill from "quill"; 22 | import ImageUploader from "quill-image-uploader"; 23 | 24 | import 'quill-image-uploader/dist/quill.imageUploader.min.css'; 25 | 26 | Quill.register("modules/imageUploader", ImageUploader); 27 | 28 | const quill = new Quill(editor, { 29 | // ... 30 | modules: { 31 | // ... 32 | imageUploader: { 33 | upload: (file) => { 34 | return new Promise((resolve, reject) => { 35 | setTimeout(() => { 36 | resolve( 37 | "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/JavaScript-logo.png/480px-JavaScript-logo.png" 38 | ); 39 | }, 3500); 40 | }); 41 | }, 42 | }, 43 | }, 44 | }); 45 | ``` 46 | 47 | ### Quickstart (React with react-quill) 48 | 49 | React Example on [CodeSandbox](https://codesandbox.io/s/react-quill-demo-qr8xd) 50 | 51 | ### Quickstart (script tag) 52 | 53 | Example on [CodeSandbox](https://codesandbox.io/s/mutable-tdd-lrsvh) 54 | 55 | ```javascript 56 | // A link to quill.js 57 | 58 | 59 | 60 | Quill.register("modules/imageUploader", ImageUploader); 61 | 62 | var quill = new Quill(editor, { 63 | // ... 64 | modules: { 65 | // ... 66 | imageUploader: { 67 | upload: file => { 68 | return new Promise((resolve, reject) => { 69 | setTimeout(() => { 70 | resolve( 71 | "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/JavaScript-logo.png/480px-JavaScript-logo.png" 72 | ); 73 | }, 3500); 74 | }); 75 | } 76 | } 77 | } 78 | }); 79 | ``` 80 | -------------------------------------------------------------------------------- /dist/demo.min.js: -------------------------------------------------------------------------------- 1 | !function(e){var t={};function n(i){if(t[i])return t[i].exports;var r=t[i]={i:i,l:!1,exports:{}};return e[i].call(r.exports,r,r.exports,n),r.l=!0,r.exports}n.m=e,n.c=t,n.d=function(e,t,i){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:i})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var r in e)n.d(i,r,function(t){return e[t]}.bind(null,r));return i},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=3)}([function(e,t){e.exports=Quill},function(e,t,n){"use strict";var i=n(0),r=n.n(i),o=function(){function e(e,t){for(var n=0;n 2 | 3 | 4 | 5 | 6 | Quill Demo 7 | 8 | 9 | 13 | 14 | 15 | 16 | 17 | 18 |

19 | Demo of uploading an image to a server instead of base64 encoding images 20 |

21 | 22 |
23 |

Select the image button from the toolbar

24 |


25 |

The file will be past to your Upload function.

26 |


27 |

Return a Promise that resolves as a url of an image

28 |


29 |

30 | This demo has a timeout to simulate uploading to a server and resolves 31 | as as url to an image 32 |

33 |
34 | 35 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/demo.js: -------------------------------------------------------------------------------- 1 | import Quill from "quill"; 2 | import ImageUploader from "./quill.imageUploader.js"; 3 | 4 | Quill.debug("warn"); 5 | Quill.register("modules/imageUploader", ImageUploader); 6 | 7 | const fullToolbarOptions = [ 8 | [{ header: [1, 2, 3, false] }], 9 | ["bold", "italic"], 10 | ["clean"], 11 | ["image"], 12 | ]; 13 | var quill = new Quill("#editor", { 14 | theme: "snow", 15 | modules: { 16 | toolbar: { 17 | container: fullToolbarOptions, 18 | }, 19 | imageUploader: { 20 | upload: (file) => { 21 | const fileReader = new FileReader(); 22 | return new Promise((resolve, reject) => { 23 | fileReader.addEventListener( 24 | "load", 25 | () => { 26 | let base64ImageSrc = fileReader.result; 27 | setTimeout(() => { 28 | resolve(base64ImageSrc); 29 | //reject('Issue uploading file'); 30 | }, 1500); 31 | }, 32 | false 33 | ); 34 | 35 | if (file) { 36 | fileReader.readAsDataURL(file); 37 | } else { 38 | reject("No file selected"); 39 | } 40 | }); 41 | }, 42 | }, 43 | }, 44 | }); 45 | 46 | quill.on("text-change", function(delta, oldDelta, source) { 47 | if (source == "api") { 48 | console.log("An API call triggered this change."); 49 | } else if (source == "user") { 50 | console.log("A user action triggered this change."); 51 | } 52 | console.log(oldDelta, delta); 53 | }); 54 | 55 | quill.on("selection-change", function(range, oldRange, source) { 56 | if (range) { 57 | if (range.length == 0) { 58 | console.log("User cursor is on", range.index); 59 | } else { 60 | var text = quill.getText(range.index, range.length); 61 | console.log("User has highlighted", text); 62 | } 63 | } else { 64 | console.log("Cursor not in the editor"); 65 | } 66 | }); -------------------------------------------------------------------------------- /src/dist.js: -------------------------------------------------------------------------------- 1 | export { default } from "./quill.imageUploader"; 2 | 3 | import "./quill.imageUploader.css"; 4 | -------------------------------------------------------------------------------- /src/quill.imageUploader.css: -------------------------------------------------------------------------------- 1 | .image-uploading { 2 | position: relative; 3 | display: inline-block; 4 | } 5 | 6 | .image-uploading img { 7 | max-width: 98% !important; 8 | filter: blur(5px); 9 | opacity: 0.3; 10 | } 11 | 12 | .image-uploading::before { 13 | content: ""; 14 | box-sizing: border-box; 15 | position: absolute; 16 | top: 50%; 17 | left: 50%; 18 | width: 30px; 19 | height: 30px; 20 | margin-top: -15px; 21 | margin-left: -15px; 22 | border-radius: 50%; 23 | border: 3px solid #ccc; 24 | border-top-color: #1e986c; 25 | z-index: 1; 26 | animation: spinner 0.6s linear infinite; 27 | } 28 | 29 | @keyframes spinner { 30 | to { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/quill.imageUploader.js: -------------------------------------------------------------------------------- 1 | import LoadingImage from "./blots/image.js"; 2 | 3 | class ImageUploader { 4 | constructor(quill, options) { 5 | this.quill = quill; 6 | this.options = options; 7 | this.range = null; 8 | this.placeholderDelta = null; 9 | 10 | if (typeof this.options.upload !== "function") 11 | console.warn( 12 | "[Missing config] upload function that returns a promise is required" 13 | ); 14 | 15 | var toolbar = this.quill.getModule("toolbar"); 16 | if (toolbar) { 17 | toolbar.addHandler("image", this.selectLocalImage.bind(this)); 18 | } 19 | 20 | this.handleDrop = this.handleDrop.bind(this); 21 | this.handlePaste = this.handlePaste.bind(this); 22 | 23 | this.quill.root.addEventListener("drop", this.handleDrop, false); 24 | this.quill.root.addEventListener("paste", this.handlePaste, false); 25 | } 26 | 27 | selectLocalImage() { 28 | this.quill.focus(); 29 | this.range = this.quill.getSelection(); 30 | this.fileHolder = document.createElement("input"); 31 | this.fileHolder.setAttribute("type", "file"); 32 | this.fileHolder.setAttribute("accept", "image/*"); 33 | this.fileHolder.setAttribute("style", "visibility:hidden"); 34 | 35 | this.fileHolder.onchange = this.fileChanged.bind(this); 36 | 37 | document.body.appendChild(this.fileHolder); 38 | 39 | this.fileHolder.click(); 40 | 41 | window.requestAnimationFrame(() => { 42 | document.body.removeChild(this.fileHolder); 43 | }); 44 | } 45 | 46 | handleDrop(evt) { 47 | if ( 48 | evt.dataTransfer && 49 | evt.dataTransfer.files && 50 | evt.dataTransfer.files.length 51 | ) { 52 | evt.stopPropagation(); 53 | evt.preventDefault(); 54 | if (document.caretRangeFromPoint) { 55 | const selection = document.getSelection(); 56 | const range = document.caretRangeFromPoint(evt.clientX, evt.clientY); 57 | if (selection && range) { 58 | selection.setBaseAndExtent( 59 | range.startContainer, 60 | range.startOffset, 61 | range.startContainer, 62 | range.startOffset 63 | ); 64 | } 65 | } else { 66 | const selection = document.getSelection(); 67 | const range = document.caretPositionFromPoint(evt.clientX, evt.clientY); 68 | if (selection && range) { 69 | selection.setBaseAndExtent( 70 | range.offsetNode, 71 | range.offset, 72 | range.offsetNode, 73 | range.offset 74 | ); 75 | } 76 | } 77 | 78 | this.quill.focus(); 79 | this.range = this.quill.getSelection(); 80 | let file = evt.dataTransfer.files[0]; 81 | 82 | setTimeout(() => { 83 | this.quill.focus(); 84 | this.range = this.quill.getSelection(); 85 | this.readAndUploadFile(file); 86 | }, 0); 87 | } 88 | } 89 | 90 | handlePaste(evt) { 91 | let clipboard = evt.clipboardData || window.clipboardData; 92 | 93 | // IE 11 is .files other browsers are .items 94 | if (clipboard && (clipboard.items || clipboard.files)) { 95 | let items = clipboard.items || clipboard.files; 96 | const IMAGE_MIME_REGEX = /^image\/(jpe?g|gif|png|svg|webp)$/i; 97 | 98 | for (let i = 0; i < items.length; i++) { 99 | if (IMAGE_MIME_REGEX.test(items[i].type)) { 100 | let file = items[i].getAsFile ? items[i].getAsFile() : items[i]; 101 | 102 | if (file) { 103 | this.quill.focus(); 104 | this.range = this.quill.getSelection(); 105 | evt.preventDefault(); 106 | setTimeout(() => { 107 | this.quill.focus(); 108 | this.range = this.quill.getSelection(); 109 | this.readAndUploadFile(file); 110 | }, 0); 111 | } 112 | } 113 | } 114 | } 115 | } 116 | 117 | readAndUploadFile(file) { 118 | let isUploadReject = false; 119 | 120 | const fileReader = new FileReader(); 121 | 122 | fileReader.addEventListener( 123 | "load", 124 | () => { 125 | if (!isUploadReject) { 126 | let base64ImageSrc = fileReader.result; 127 | this.insertBase64Image(base64ImageSrc); 128 | } 129 | }, 130 | false 131 | ); 132 | 133 | if (file) { 134 | fileReader.readAsDataURL(file); 135 | } 136 | 137 | this.options.upload(file).then( 138 | (imageUrl) => { 139 | this.insertToEditor(imageUrl); 140 | }, 141 | (error) => { 142 | isUploadReject = true; 143 | this.removeBase64Image(); 144 | console.warn(error); 145 | } 146 | ); 147 | } 148 | 149 | fileChanged() { 150 | const file = this.fileHolder.files[0]; 151 | this.readAndUploadFile(file); 152 | } 153 | 154 | insertBase64Image(url) { 155 | const range = this.range; 156 | 157 | this.placeholderDelta = this.quill.insertEmbed( 158 | range.index, 159 | LoadingImage.blotName, 160 | `${url}`, 161 | "user" 162 | ); 163 | } 164 | 165 | insertToEditor(url) { 166 | const range = this.range; 167 | 168 | const lengthToDelete = this.calculatePlaceholderInsertLength(); 169 | 170 | // Delete the placeholder image 171 | this.quill.deleteText(range.index, lengthToDelete, "user"); 172 | // Insert the server saved image 173 | this.quill.insertEmbed(range.index, "image", `${url}`, "user"); 174 | 175 | range.index++; 176 | this.quill.setSelection(range, "user"); 177 | } 178 | 179 | // The length of the insert delta from insertBase64Image can vary depending on what part of the line the insert occurs 180 | calculatePlaceholderInsertLength() { 181 | return this.placeholderDelta.ops.reduce((accumulator, deltaOperation) => { 182 | if (deltaOperation.hasOwnProperty('insert')) 183 | accumulator++; 184 | 185 | return accumulator; 186 | }, 0); 187 | } 188 | 189 | removeBase64Image() { 190 | const range = this.range; 191 | const lengthToDelete = this.calculatePlaceholderInsertLength(); 192 | 193 | this.quill.deleteText(range.index, lengthToDelete, "user"); 194 | } 195 | } 196 | 197 | window.ImageUploader = ImageUploader; 198 | export default ImageUploader; -------------------------------------------------------------------------------- /static/quill-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NoelOConnell/quill-image-uploader/ddb1e542d10e918a23a6933595495457881b6bcc/static/quill-example.gif -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const TerserPlugin = require("terser-webpack-plugin"); 3 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 4 | 5 | module.exports = [{ 6 | entry: { 7 | "quill.imageUploader": "./src/dist.js", 8 | demo: "./src/demo.js", 9 | }, 10 | output: { 11 | filename: "[name].min.js", 12 | path: path.resolve(__dirname, "dist"), 13 | }, 14 | devServer: { 15 | //contentBase: './src', 16 | https: true, 17 | }, 18 | externals: { 19 | quill: "Quill", 20 | }, 21 | optimization: { 22 | minimize: true, 23 | minimizer: [ 24 | new TerserPlugin({ 25 | extractComments: true, 26 | cache: true, 27 | parallel: true, 28 | sourceMap: true, // Must be set to true if using source-maps in production 29 | terserOptions: { 30 | // https://github.com/webpack-contrib/terser-webpack-plugin#terseroptions 31 | extractComments: "all", 32 | compress: { 33 | drop_console: false, 34 | }, 35 | }, 36 | }), 37 | ], 38 | }, 39 | module: { 40 | rules: [{ 41 | test: /\.css$/, 42 | use: ExtractTextPlugin.extract({ 43 | use: [{ 44 | loader: "css-loader", 45 | }, ], 46 | }), 47 | }, 48 | { 49 | test: /\.js$/, 50 | exclude: /node_modules/, 51 | use: { 52 | loader: "babel-loader", 53 | }, 54 | }, 55 | ], 56 | }, 57 | plugins: [new ExtractTextPlugin("quill.imageUploader.min.css")], 58 | }, ]; --------------------------------------------------------------------------------