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 | }, ];
--------------------------------------------------------------------------------