Created | Urls | Favourites | Opened | Upvotes | Comments |
5 | 1 | 367 | 0 | 2 |
Updated Jul 2021. How to custom build a CKEditor 5 with support for image upload using either Fetch or xmlHttpRequest - step by step tutorial.
Index :
Appendixes :
Note that you can follow this tutorial without prior experience with CKEdtior 5, however If you have not built a CKEditor 5 yet, you will benefit from reading the Building CKEditor 5 From Source tutorial first to learn how to built a CKEditor 5 from source.
As fantastic as CKEditor 5 is (and it is), none of the CKEditor 5 pre-builds come with an out-of-the-box image upload (unless you pay for it) making image upload more cumbersome than one could wish for.
To enable image upload in CKEdtior 5, you earlier had the following options :
However, the CKEditor team have finally supplied a SIMPLE official UploadAdapter, unfortunately it does not really change the game as you still need to build CKEditor 5 from source to implement it and so everything is still the same. However, where CKEditor 5 fails in regard to free image upload, it makes up for in flexible customization and easy plugin creation - implementing our own UploadAdapter is going to be a breeze.
Before we can get started on building the UploadAdapter plugin, we need to first build a dummy CKEditor 5 to host our UploadAdapter plugin - let's get it done!
Ideally you already know how to build a CKEditor 5 from Source, however I include this section to help you if you don't (I have also created a more extensive tutorial on how to Building CKEditor 5 from Source if you want a better grasp on it).
So this section is not to teach you in dept how to build CKEditor 5 from source but instead to just fast show you, so we can continue with our real goal - building an UploadAdapter plugin.
After installation confirm that Node & NPM was indeed installed:
Ok, Node & NPM should now be installed on your Windows system.
Since we are building both a plugin and a dummy CKEditor 5, we need to construct an appropriate folder structure to organize all of the code - I generally use the following template based folder structure for CKEditor 5 plugin development : (only create the 6 folders in bold, the 5 non-bold folders will be automatically created later).
With the main folder structure in place, we can start creating the dummy CKEditor 5 :
{
"dependencies": {
"@ckeditor/ckeditor5-editor-classic": "x",
"@ckeditor/ckeditor5-core": "x",
"@ckeditor/ckeditor5-theme-lark": "x",
"@ckeditor/ckeditor5-essentials": "x",
"@ckeditor/ckeditor5-basic-styles": "x",
"@ckeditor/ckeditor5-heading": "x",
"@ckeditor/ckeditor5-image": "x",
"@ckeditor/ckeditor5-inspector": "2.2.0"
},
"devDependencies": {
"@ckeditor/ckeditor5-dev-utils": "x",
"postcss-loader": "3.0.0",
"raw-loader": "4.0.1",
"style-loader": "1.2.1",
"webpack": "^4.44.2",
"webpack-cli": "^3.3.12"
}
}
// C:\CKUploadAdapter\CKUploadAdapterDev\app.js
import ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Image from '@ckeditor/ckeditor5-image/src/image';
import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload';
import CKEditorInspector from '@ckeditor/ckeditor5-inspector';
export default class ClassicEditor extends ClassicEditorBase { }
ClassicEditor
.create(document.querySelector('#editor'), {
plugins: [
Heading,
Essentials,
Bold,
Image,
ImageUpload
],
toolbar: [
'heading',
'bold',
'imageUpload'
],
})
.then(editor => {
CKEditorInspector.attach({ 'editor': editor });
window.editor = editor;
})
.catch(error => {
console.error(error.stack);
});
// C:\CKUploadAdapter\CKUploadAdapterDev\webpack.config.js
'use strict';
const path = require('path');
const { styles } = require('@ckeditor/ckeditor5-dev-utils');
module.exports = {
entry: './app.js',
output: {
library: 'CustomEditor',
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
libraryTarget: 'var',
libraryExport: 'default'
},
module: {
rules: [
{
test: /\.svg$/,
use: ['raw-loader']
},
{
test: /\.css$/,
use: [
{
loader: 'style-loader',
options: {
injectType: 'singletonStyleTag',
attributes: {
'data-cke': true
}
}
},
{
loader: 'postcss-loader',
options: styles.getPostCssConfig({
themeImporter: {
themePath: require.resolve('@ckeditor/ckeditor5-theme-lark')
},
minify: true
})
},
]
}
]
},
mode: 'development',
// Useful for debugging.
devtool: 'source-map',
// By default webpack logs warnings if the bundle is bigger than 200kb.
performance: { hints: false }};
<!-- C:\CKUploadAdapter\CKUploadAdapterDev\index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>CKEditor 5 - Creating an UploadAdapter Plugin</title>
</head>
<body>
<div id="editor">
<p>Editor content goes here.
</div>
<script src="dist/bundle.js"></script>
</body>
</html>
Ok, the dummy CKEditor 5 have been build, we can now start on our real quest - building a plugin.
As with most things then first you understand it image upload is actually quite simple if you are an experienced javascripter - there really is not a lot to it.
When we build a plugin, we need a CKEditor 5 to house it while we build it - therefore we need to start with a CKEditor 5 build - that's why we build the above dummy CKEditor 5.
First let's see what happens if we try to upload an image without any UploadAdapter :
So let's get building that UploadAdapter :
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
import UploadAdapterImplementation from './uploadadapterimplementation';
export default class UploadAdapter extends Plugin {
static get requires() { // FileRepository is already loaded (added to editor.plugins), however the official documentation recommends loading FileRepository here
return [FileRepository]; // adds FileRepository to this.editor.plugins
}
init() {
// Configuration (we don't need to define the configuration schema, we simply request it to see if it is there)
const uploadUrl = this.editor.config.get('uploadAdapter.uploadUrl');const useFetch = this.editor.config.get('uploadAdapter.useFetch');
const headers = this.editor.config.get('uploadAdapter.headers');
const t = this.editor.t; // translation function used for localization
if (!uploadUrl) { // uploadUrl (http endpoint) is mandatory
console.warn('UploadAdapter : uploadUrl is not defined.');return;
}
/*
Here we define the createUploadAdapter factory function on the FileRepository.
CKEditor 5 framework (ImageUpload) will call createUploadAdapter passing in a FileLoader object (holding a File promise, which will resolve as the user file is uploaded typically from the users harddrive)
In our custom createUploadAdapter we create a new UploadAdapterImplementation object passing the FileLoader and configurations as well as localisation (t) to the constructor
*/
this.editor.plugins.get('FileRepository').createUploadAdapter = (fileLoader) => {
return new UploadAdapterImplementation(fileLoader, uploadUrl, useFetch, headers, t);
};
}
}
Note that the above UploadAdapterImplementation does not extend anything as Javascript does not technically have interfaces, however we can still use the idea of an interface - allowing the CKEditor 5 framework to call functions on our custom implementations, here upload() & abort().export default class UploadAdapterImplementation {
constructor(loader, uploadUrl, useFetch, headers, t) {
this.loader = loader; // FileLoader with the .file promise
this.uploadUrl = uploadUrl;this.useFetch = useFetch && window.fetch; // xmlHttpRequest is default and also fallback to xmlHttpRequest if fetch is not supported
this.headers = headers;this.t = t;
}
upload() { // interface function
return this.loader.file.then(file => new Promise((resolve, reject) => {
this.genericErrorText = "Couldn\'t upload file: " + file.name + ".";
this.data = new FormData();
this.data.append('upload', file);
if (this.useFetch) {
this._fetchRequest(resolve, reject);
}
else {
this._xhrRequest(resolve, reject);
}
}));
}
abort() { // interface function
if (this.useFetch) {if (this.abortController) {
this.abortController.abort();
}
}
else {
if (this.xhr) {
this.xhr.abort();
}
}
}
_fetchRequest(resolve, reject) {
this.abortController = new AbortController();
fetch(this.uploadUrl, {
signal: this.abortController.signal,
method: 'POST',
headers: this.headers || {},
body: this.data
})
.then(response => {
return response.json();
})
.then(json => {
resolve({
default: json.url
});
})
.catch(error => {
if (error.name === "AbortError") {
return reject();
}
return reject("ERROR Uploading : " + (error || this.genericErrorText));
});
}
_xhrRequest(resolve, reject) {
console.log("_xhrRequest");
this.xhr = new XMLHttpRequest();
this.xhr.open('post', this.uploadUrl, true);
this.xhr.responseType = 'json';
this.xhr.addEventListener('error', () => reject(this.genericErrorText));
this.xhr.addEventListener('abort', () => reject());
this.xhr.addEventListener('load', () => {
const response = this.xhr.response;
if (!response || response.error) {
return reject(response && response.error ? response.error.message : this.genericErrorText);
}
resolve({
default: response.url
});
});
if (this.xhr.upload) { // upload progress is available for xmlHttpRequest only
this.xhr.upload.addEventListener('progress', evt => {if (evt.lengthComputable) {
this.loader.uploadTotal = evt.total;
this.loader.uploaded = evt.loaded;
}
});
}
if (this.headers) {
var headers = Object.keys(this.headers);
for (var h = 0; h < headers.length; h++) {
this.xhr.setRequestHeader(headers[h], this.headers[headers[h]]);
}
}
this.xhr.send(this.data);
}
}
// C:\CKUploadAdapter\CKUploadAdapterDev\app.js
import ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Image from '@ckeditor/ckeditor5-image/src/image';
import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload';
import UploadAdapter from './src/uploadadapter'; // ADD THIS (1/3)
import CKEditorInspector from '@ckeditor/ckeditor5-inspector';
export default class ClassicEditor extends ClassicEditorBase { }
ClassicEditor
.create(document.querySelector('#editor'), {
plugins: [
Heading,
Essentials,
Bold,
Image,
ImageUpload, // <-- don't forget a comma here
UploadAdapter // ADD THIS (2/3)
],toolbar: [
'heading',
'bold',
'imageUpload'
], // <-- don't forget a comma here
// ADD THIS (3/3)
uploadAdapter: {uploadUrl: '/Image/ImageUpload', // http endpoint is mandatory
useFetch: true, // optional (default is false)
headers: null // optional, eg. { "headerKey1": "headerValue1", "headerKey2": "headerValue2" }. In asp.net core for CSRF prevention you would have headers : { "RequestVerificationToken": _serverSideGeneratedCSRFToken }
}
})
.then(editor => {
CKEditorInspector.attach({ 'editor': editor });
window.editor = editor;
})
.catch(error => {
console.error(error.stack);
});
Then an image is added to CKEditor 5, the ImageUpload plugin uses our UploadAdapter to post the image to a server side HTTP endpoint - you have specified that endpoint in the UploadAdapter configuration :
uploadAdapter: {
uploadUrl: '/Image/ImageUpload', // http endpoint
useFetch: true,//headers: { "headerKey1": "headerValue1", "headerKey2": "headerValue2" } // optional. Eg. in asp.net core for CSRF prevention you would have headers : { "RequestVerificationToken": _serverSideGeneratedCSRFToken }
}
The HTTP endpoint (above '/Image/ImageUpload') must save the image somewhere and return a url to the saved image - this url will be passed from UploadAdapter to the ImageUpload plugin, which will then update the image element in the CKEditor 5 model to use the returned image url.
Depending on programming language and system setup, there are a multitude of ways to implement such an HTTP Endpoint - here I will show an example in ASP.NET Core C#.
public class ImagesController : Controller
{
private readonly IWebHostEnvironment _hostingEnvironment;
public ImagesController(IWebHostEnvironment hostingEnvironment)
{
_hostingEnvironment = hostingEnvironment;
}
[HttpPost]
//[IgnoreAntiforgeryToken] //if you have CSRF global prevention enabled but have not added CRSF to the UploadAdapter headers, then you must disable CSRF prevention here using [IgnoreAntiforgeryToken], otherwise you will get an error.
public async Task<JsonResult> ImageUpload(IFormFile upload){
var headers = Request.Headers;
var contentType = upload.ContentType;
var contentDisposition = upload.ContentDisposition;
var filelength = upload.Length;
if (filelength == 0)
{
return Json(new
{
uploaded = false,
error = "Image file have no length."
});
}
var filename = upload.FileName;
var filetype = ""; if (filename.IndexOf('.') != -1) { filetype = filename.Substring(filename.LastIndexOf('.')); }
if (filetype == "")
{
return Json(new
{
uploaded = false,
error = "Image file extension is not valid."
});
}
var targetFilename = Guid.NewGuid().ToString().Replace("-", "") + filetype;
var imgTempPath = TempFolderPath(_hostingEnvironment, targetFilename);
if (upload.Length > 0)
{
using (var fileStream = new FileStream(imgTempPath, FileMode.Create))
{
await upload.CopyToAsync(fileStream);
}
}
var imageTempUrl = TempFolderUrl + targetFilename;
return Json(new
{
uploaded = true,
url = imageTempUrl
});
}
public static string TempFolderPath(IWebHostEnvironment hostingEnvironment, string imageNameOrUrl = "")
{
var pathWebroot = hostingEnvironment.WebRootPath;
var pathUploads = Path.Combine(pathWebroot, "uploads");
if (!pathUploads.EndsWith('\\')) { pathUploads += '\\'; }
var imageName = imageNameOrUrl;
var indexOfLastSlash = imageNameOrUrl.LastIndexOf('/');
if (indexOfLastSlash > 0 && imageNameOrUrl.Length > indexOfLastSlash)
{
imageName = imageNameOrUrl.Substring(indexOfLastSlash + 1);
}
return pathUploads + imageName;
}
public static string TempFolderUrl
{
get { return "/uploads/"; }
}
}
}
To prevent CSRF (Cross Site Request Forgery) attacks, you can set a server side created CSRF token in the XmlHttpHeader (to send back to the server).