Middleware-based clipboard handling - meet clipboar 🐗
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 )
:
- The list of modifiers is copied into the
modifiers
array. - You call
next()
. - The
next()
function fetches the first modifier from themodifiers
array and calls it, passingobject
andnext()
itself as parameters. - The modifier adds the
occupation
property to theobject
and callsnext()
. - The
next()
function fetches the next modifier from themodifiers
array and calls it, passingobject
andnext()
itself as parameters. - The modifier adds the
age
property to the object. - As
next()
was not called in the last step, the execution ofmodify()
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()
anddataTransfer.setData()
methods or via thedataTransfer.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 Promise
s, 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: