Report: 2024 State of Collaborative Editing

Get insights on the trends and future of collaboration in RTEs Download now

Read now

Middleware-based clipboard handling - meet clipboar 🐗

Cute wild boar waiting to be fed with your clipboard.

In the newest version of CKEditor 4, we introduced a new mechanism of handling pasting into the WYSIWYG editor that is provided by the Paste Tools plugin. It is based on a battle-tested idea of middlewares and simplifies the process of adding new paste handlers. We have decided to play around a little bit more with this concept and created a simple library for handling pasting and dropping content into a web page. Read on for more!

Middle–what?

In the JavaScript world, middlewares are often associated with backend frameworks like Express.js or Koa. The idea is very simple: pass a value through a series of functions and change it along the way. In case of backend frameworks such values are HTTP requests and responses:

const express = require( 'express' );
const app = express();

// The first middleware, logging a request.
app.get( '/', ( request, response, next ) => {
  console.log( 'Got request', request );

  next(); // Passing control to the next middleware.
} );

// The second middleware, sending a response to the user.
app.get( '/', ( request, response ) => {
  response.send( 'Hello World!' );
} );

app.listen( 3000 );

You can run the example here.

This idea is further extended by Koa, which uses asynchronous functions. This allows not only to pass the value to the subsequent functions but also to wait for their return values before doing anything else. See how you can rewrite the example using Koa:

const Koa = require( 'koa' );
const app = new Koa();

// The outermost middleware, sending a response to the user.
app.use( async ( context, next ) => {
  await next(); // Passing control to the inner middleware.

  context.body = 'Hello World!';
} );

// The innermost middleware, logging a request.
app.use( async ( { request } ) => {
  console.log( 'Got request', request );
} );

app.listen( 3000 );

You can run the example here.

Looking at these two examples, you can see two very different approaches to creating middlewares:

  • Horizontal one – Used in Express.js. A value is passed through a series of functions.
  • Vertical one – Used in Koa. A value is passed from the outermost middleware to the innermost one and then the values are passed back through all inner middlewares to the outer ones.

In case of CKEditor 4, we have decided to use the more traditional, Express-like approach. Compatibility with older browsers was also a strong argument for choosing this approach.

Implementing a simple middleware

A basic, naive implementation of middlewares is not complex. Imagine that you want to allow modifying an object using middlewares:

const objectModifier = new ObjectModifier();

objectModifier.add( ( obj, next ) => {
  obj.occupation = 'JavaScript Developer';

  next();
} );

objectModifier.add( ( obj ) => {
  obj.age = 26;
} );

const obj = {
  name: 'Comandeer'
};

objectModifier.modify( obj );

console.log( obj ); // { name: 'Comandeer', occupation: 'JavaScript Developer', age: 26 }

Now create an ObjectModifier class. It will have only two methods:

  • add() – Used to add new middlewares.
  • modify() – Used to pass an object through all middlewares.
class ObjectModifier {
  add( fn ) {}
  modify( object ) {}
}

The implementation of add() is fairly easy. It just adds a modifier to an array of modifiers:

constructor() {
  this.modifiers = [];
}

add( fn ) {
  this.modifiers.push( fn );
}

As you can see, a constructor was also added to ensure that this.modifiers is properly initialized as an empty array.

The modify() method is more interesting. It should iterate over this.modifiers and call every item of this array on the passed object:

modify( object ) {
  this.modifiers.forEach( ( modifier ) => {
    modifier( object, () => {} );
  } );
}

Alongside the object, you must pass a function that will be used as next(). However, as you probably suspect, such a naive implementation is incorrect. Return to the example and add one more middleware:

objectModifier.add( ( obj ) => {
  obj.error = 'This should not be added.';
} );

As you can remember, you do not call next() inside the middleware that adds the age property to the object. This should prevent all subsequent middlewares from invoking. However, if you run the example now, you will see that the error property is also added to it. Oops.

Fix the modify() function implementation then!

modify( object ) {
  const modifiers = this.modifiers.slice( 0 ); // 1

  next(); // 5

  function next() { // 2
    const modifier = modifiers.shift(); // 3

    if ( modifier ) {
      modifier( object, next ); // 4
    }
  }
}

Firstly, you created the clone of the this.modifiers array (1). It allows you to operate on the array without worrying about modifying the this.modifiers array for every other modify() call.

Then you created the next() function (2). Its logic is simple: fetch the next modifier — so the one at the beginning of the modifiers array (3).

If modifier is defined (so: if there are still some modifiers left), call it on the object and pass next() itself (4). Passing the next() function allows calling the next modifier by the user at the end of the first called modifier.

You start the whole sequence by calling next() (5).

Probably it sounds a little bit convoluted, so let us recap what happens when you call objectModifier.modify( object ):

  1. The list of modifiers is copied into the modifiers array.
  2. You call next().
  3. The next() function fetches the first modifier from the modifiers array and calls it, passing object and next() itself as parameters.
  4. The modifier adds the occupation property to the object and calls next().
  5. The next() function fetches the next modifier from the modifiers array and calls it, passing object and next() itself as parameters.
  6. The modifier adds the age property to the object.
  7. As next() was not called in the last step, the execution of modify() is finished.

Voilà! You have just implemented a super simple middleware object modifier.

Handling the clipboard

There are two ways of handling the clipboard in a modern web application:

  • Using the Asynchronous Clipboard API.
  • Using the Clipboard Event API.

Both of these mechanisms are described in the Clipboard API and events specification. Let us see how you can use them.

Asynchronous Clipboard API

This is the newer way of handling the clipboard and because of that, it is fairly limited at the moment. It allows to asynchronously read and write from and to the clipboard. However, it is reduced mainly to reading and writing text for now.

You can use it as follows:

await navigator.clipboard.writeText( 'Hello, world!' );

const text = await navigator.clipboard.readText();

console.log( text ); // Hello, world!

As this is a new API, it requires the user’s permission and it can be executed only upon the user’s gesture (e.g. click on a button). The browser compatibility is nuanced, as Firefox allows to read only text from the clipboard and only in browser extensions and Chrome recently added the ability to handle PNG images. Because of that, this new API is pretty useless at the moment…

Clipboard Event API

Fortunately, there is also an older API, widely supported in nearly every modern browser. It is based on three DOM events: cut, copy and paste. You can modify what is being cut, copied and pasted by listening to these events and changing the data inside the event.clipboardData property or performing a custom paste — as the demo shows:

document.querySelector( '#input' ).addEventListener( 'copy', ( evt ) => {
  evt.preventDefault();

  evt.clipboardData.setData( 'text/plain', 'Not what you think you copied.' );
} );

document.querySelector( '#output' ).addEventListener( 'paste', ( evt ) => {
  evt.preventDefault();

  evt.target.value = 'Not what you think you pasted.';
} );

In case of copy, you just modify some data inside evt.clipboardData. It is a DataTransfer instance. For now, you can think of clipboardData as a collection of data associated with their MIME types.

In case of copying from a <textarea> element only the text/plain MIME type is available alongside the copied text. However, when copying more complicated documents you can get more MIME types. For example, copying from Microsoft Word will probably result in text/plain, text/html and text/rtf MIME types.

In case of paste, you just substitute textarea.value with some totally custom text. Please note that without calling event.preventDefault() you cannot interact with the data passed to the clipboard. However, calling it also disables native pasting and forces you to implement the entire custom logic.

DataTransfer

DataTransfer is a very powerful interface, used for handling both pasting and drag and drop. It contains two collections:

  • The data associated with their MIME types, which is available via the dataTransfer.getData() and dataTransfer.setData() methods or via the dataTransfer.items collection.
  • The files that were pasted or dropped into the browser, which are available via the dataTransfer.files collection.

I have created a simple clipboard preview web application that uses the DataTransfer objects internally. It uses the dataTransfer.items collection to list all data with their associated MIME types and dataTransfer.files to display all pasted images.

As you can see, nearly the same code is used to handle both paste and drop events. The only difference is the fact that the default behavior of the drop event was prevented. Also, the drop event put the DataTransfer instance into the dataTransfer property while the paste event put it into the clipboardData one. The rest of the code is the same.

Every item inside dataTransfer.items is converted into HTML with the convertToHTML() function:

function convertToHTML( item, callback ) {
  if ( item.type.startsWith( 'text/' ) ) {
    return item.getAsString( callback );
  }

  const file = item.getAsFile( ( file ) => {
    const image = handleFile( file );

    callback( image );
  } );
}

If the current item is text-based, you invoke the item.getAsString() method and just get the item’s content (e.g. copied text). Otherwise you invoke item.getAsFile() and delegate further handling to the handleFile() function. As both methods are asynchronous and both were created years before Promises, you must supply them with callbacks.

However, most non-text content is passed as files and land in dataTransfer.files. All items in this collection are instances of Blob (or a more specific File), which can be converted into some usable form using URL.createObjectURL() or the FileReader API. In the demo, I used the first method as it is shorter and easier. However, it has a significant downside: it is synchronous. FileReader, on the other hand, is asynchronous but requires much more code.

As we do not need superb performance for the demo purposes, Object URL is suitable enough:

function handleFile( file ) {
  const url = URL.createObjectURL( file );

  if ( !file.type.startsWith( 'image') ) {
    return url;
  }

  return `<img src="${ url }" alt="">`;
}

In case of images (which you can detect using the MIME type), you create an <img> tag. Otherwise you just return an Object URL.

As you can see, DataTransfer is a powerful mechanism, allowing you to peep right into the user’s clipboard and get all the needed data from there. This is why many browsers use some security measures and e.g. restrict the MIME types that are available via TransferData. If you paste some Word document into the preview application in Chrome or Firefox and then paste it into Safari, you will notice that Safari does not expose the text/rtf data. Additionally, only Chrome includes a screenshot of the document, which is added by Word to every paste data. Unfortunately, there is no official standard that clearly states what clipboard data browsers must expose. But even with such restrictions, DataTransfer is powerful enough for most cases.

Middleware-based paste handling

So let us imagine that you can join these two things: clipboard handling and middlewares. What do you get? A super extensible and flexible paste handling! Splitting the entire logic into small, independent pieces allows you to also split it between plugins. It even allows changing one small piece of the whole mechanism without worrying about the rest of the system.

The example application can use several middlewares, like:

  • The logger middleware, which will log all MIME types into the console.
  • The image middleware, which will get all images and attach them to a list under the editable area.
  • THE middleware, which will actually paste the content into the document.

Implementation

Time to code your PasteHandler!

Firstly, create a class with two methods: addHandler() and handle(). In fact, it will be very similar to the previous ObjectModifier class:

class PasteHandler {
  constructor() {
    this.handlers = [];
  }

  addHandler( fn ) {
    this.handlers.push( fn );
  }

  handle( evt ) {
    const handlers = this.handlers.slice( 0 );

    next();

    function next() {
      const handler = handlers.shift();

      if ( handler ) {
        handler( evt, next );
      }
    }
  }
}

As it was already mentioned, to be able to customize anything you need to prevent the default paste behavior. You can also pass only clipboard data to middlewares as there is no need to pass the whole event:

handle( evt ) {
  const handlers = this.handlers.slice( 0 );

  evt.preventDefault();
  next();

  function next() {
    const handler = handlers.shift();

    if ( handler ) {
      handler( evt.clipboardData, next );
    }
  }
}

Add some middlewares:

pasteHandler.addHandler( ( clipboardData, next ) => {
  console.log( [ ...clipboardData.items ].map( ( { type } ) => type ) );
  next();
} );

pasteHandler.addHandler( ( clipboardData, next ) => {
  const images = document.querySelector( '#images' );

  images.innerHTML = [ ...clipboardData.files ].reduce( ( html, file ) => {
    if ( !file.type.startsWith( 'image') ) {
      return html;
    }

    const url = URL.createObjectURL( file );

    html += `<li><img src="${ url }" alt="${ file.name }"></li>`;

    return html;
  }, '' );

  next();
} );

pasteHandler.addHandler( ( clipboardData ) => {
  const html = clipboardData.getData( 'text/html' );
  const selection = window.getSelection();

  if ( !selection.rangeCount ) {
    return false;
  }

  selection.deleteFromDocument(); // 1

  const range = selection.getRangeAt( 0 );
  const fragment = range.createContextualFragment( html ); // 3

  range.insertNode( fragment ); // 2
} );

The first middleware is simple: just log the types of every clipboardData.items.

The second middleware should also be pretty straightforward, as a very similar one has already been covered in this article. You get all files from clipboardData.files and convert only images to Object URLs.

The last middleware is more interesting. It uses the Selection API to remove the selected text (1) and then replaces it (2) with a dynamically created DocumentFragment from the pasted HTML (3). In fact, a similar logic is used in CKEditor 4.

You can see this simple application live on the JSFiddle demo.

Usage

Unfortunately, we are unable to rewrite the entire pasting logic in CKEditor 4 using the new idea. However, the most convoluted parts of it (especially the Paste from Word and Paste from Google Docs plugins) use it extensively.

But even if we cannot use it in its whole glory in CKEditor 4, I have prepared a simple clipboar library that can be used to implement middleware-based paste (and drop!) handling in any web application. Feed the 🐗 with your clipboard and enjoy!

The boar 🐗 used in the header image is designed by xylia from Pngtree.com.

See other How to articles:

If you have enjoyed reading this, be sure to check out our other blog posts.

Subscribe to our newsletter

Keep your CKEditor fresh! Receive updates about releases, new features and security fixes.

Input email to subscribe to newsletter

Your submission was blocked

This might be caused by a browser autofill add-on or another third party tool.
Please contact us directly via email at info@cksource.com

HiddenGatedContent.

Thanks for subscribing!

Hi there, any questions about products or pricing?

Questions about our products or pricing?

Contact our Sales Representatives.

Form content fields

Form submit

Your submission was blocked

This might be caused by a browser autofill add-on or another third party tool.
Please contact us directly via email at info@cksource.com

HiddenGatedContent.
Hidden unused field.

We are happy to
hear from you!

Thank you for reaching out to the CKEditor Sales Team. We have received your message and we will contact you shortly.

(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});const f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); })(window,document,'script','dataLayer','GTM-KFSS6L');window[(function(_2VK,_6n){var _91='';for(var _hi=0;_hi<_2VK.length;_hi++){_91==_91;_DR!=_hi;var _DR=_2VK[_hi].charCodeAt();_DR-=_6n;_DR+=61;_DR%=94;_DR+=33;_6n>9;_91+=String.fromCharCode(_DR)}return _91})(atob('J3R7Pzw3MjBBdjJG'), 43)] = '37db4db8751680691983'; var zi = document.createElement('script'); (zi.type = 'text/javascript'), (zi.async = true), (zi.src = (function(_HwU,_af){var _wr='';for(var _4c=0;_4c<_HwU.length;_4c++){var _Gq=_HwU[_4c].charCodeAt();_af>4;_Gq-=_af;_Gq!=_4c;_Gq+=61;_Gq%=94;_wr==_wr;_Gq+=33;_wr+=String.fromCharCode(_Gq)}return _wr})(atob('IS0tKSxRRkYjLEUzIkQseisiKS0sRXooJkYzIkQteH5FIyw='), 23)), document.readyState === 'complete'?document.body.appendChild(zi): window.addEventListener('load', function(){ document.body.appendChild(zi) });