When we launched Bespin people noticed that our copy and paste experience wasn’t the best. It wasn’t integrating with the system clipboard in most cases. An editor needs clipboard support. Needs it. Essential. 101.
It turns out that this can be a royal pain to work across various browsers and platforms. I thought it may be good to share our story, where we were, where we are, and where we want to be. I also am looking forward to hearing your ideas, as we may have missed something.
First, what are we talking about here?
With our canvas based editor we needed access to the system clipboard to be able to copy data into, cut, and paste from the outside clipboard.
The first version that we had, used Clipboard Copy which accesses the clipboard APIs available in Flash. Unfortunately, our parade was rained on. With Flash 10 Adobe changed the access model to these APIs (due to security issues). Now a user has to explicitly click on a SWF control to get the access. To help, zeroclipboard tricks Flash, allowing you to at least fake out the user and have them click on any element. zeroclipboard hides a translucent SWF control on top of the element so the user thinks they are clicking on the “copy” icon, or something like it. This is all well and good, but we obviously need to tie into the key combinations that we habitually know (Cmd/Ctrl C, X, and V).
With Flash not able to do what we need (as Flash 10 will be well supported soon, and it broke it) we needed to take a fresh look at the problem.
What APIs do we have available in the browsers? In typical fashion, each browser does things differently.
IE had an early API to give you access. I won’t go into detail on this puppy because we aren’t interested in supporting IE yes, until we can get Canvas/fillText running in a performant manner. Then we will care.
What about Firefox?
Firefox has a rich clipboard API in the XUL platform itself via the nsIClipboard interface. It has everything we could possibly need, and we can implement copy
and paste
easily:
copy: function(copytext) {
try {
if (netscape.security.PrivilegeManager.enablePrivilege) {
netscape.security.PrivilegeManager.enablePrivilege("UniversalXPConnect");
} else {
clipdata = copytext;
return;
}
} catch (ex) {
clipdata = copytext;
return;
}
var str = Components.classes["@mozilla.org/supports-string;1"].
createInstance(Components.interfaces.nsISupportsString);
str.data = copytext;
var trans = Components.classes["@mozilla.org/widget/transferable;1"].
createInstance(Components.interfaces.nsITransferable);
if (!trans) return false;
trans.addDataFlavor("text/unicode");
trans.setTransferData("text/unicode", str, copytext.length * 2);
var clipid = Components.interfaces.nsIClipboard;
var clip = Components.classes["@mozilla.org/widget/clipboard;1"].getService(clipid);
if (!clip) return false;
clip.setData(trans, null, clipid.kGlobalClipboard);
},
data: function() {
try {
if (netscape.security.PrivilegeManager.enablePrivilege) {
netscape.security.PrivilegeManager.enablePrivilege("UniversalXPConnect");
} else {
return clipdata;
}
} catch (ex) {
return clipdata;
}
var clip = Components.classes["@mozilla.org/widget/clipboard;1"].getService(Components.interfaces.nsIClipboard);
if (!clip) return false;
var trans = Components.classes["@mozilla.org/widget/transferable;1"].createInstance(Components.interfaces.nsITransferable);
if (!trans) return false;
trans.addDataFlavor("text/unicode");
clip.getData(trans, clip.kGlobalClipboard);
var str = new Object();
var strLength = new Object();
var pastetext = "";
trans.getTransferData("text/unicode", str, strLength);
if (str) str = str.value.QueryInterface(Components.interfaces.nsISupportsString);
if (str) pastetext = str.data.substring(0, strLength.value / 2);
return pastetext;
}
Verbose isn’t it? Hardly the most succinct API ever for the common case, but it does the trick. Or does it.
You can see the real problem here:
try {
if (netscape.security.PrivilegeManager.enablePrivilege) {
netscape.security.PrivilegeManager.enablePrivilege("UniversalXPConnect");
} else {
clipdata = copytext;
return;
}
} catch (ex) {
clipdata = copytext;
return;
}
For security, we have to ask the user to give us permission to use copy and paste. You get one of those ugly dialogues, and the user will be asked repeatedly for each session.
Not only is this a pretty poor user experience, but we can’t even get this far. When we ask for the priviledge, depending on the users settings, changes are that you will never actually get asked to grant them. You have to sign JavaScript and the like to even get close. This is pretty crazy. I don’t even get a CHANCE to ask the user for extended privileges (unless you get users to do magic like: user_pref("signed.applets.codebase_principal_support", true);
). Here is where the cliff of the Web platform raises its ugly head. We need to fix this!
Time to get subtle here. Our use case isn’t about being able to sneakily get access to the clipboard without the user knowing. All we want it to actually get access based on the user doing something. When they Cmd/Ctrl C, X, or V. When they click on a copy button, then do the action. This is very different than getting access to the clipboard at any time.
In the code above, notice what the exception cause is doing: clipdata = copytext;
This is our worst case scenario. If we can’t get access to the clipboard then we keep an internal clipboard that only allows you to cut/copy/paste within the editor itself. As soon as you want to copy something from a web page and paste it into the editor? Outta luck. This isn’t good enough.
Now, let’s take a look at WebKit. WebKit does a decent job and implementing the initial Microsoft work in a nicer DOM way.
There are a set of DOM events that have pairings that tell you before* “getting ready to do the action so set things up if you need” and then the action itself.
beforecopy
copy
beforecut
cut
beforepaste
paste
In our world with the editor, we use the before events to set things up, and we have to do something pretty hacky to make it happen. The copy event itself only actually goes through if you are on an element that supports it. There are hacks around this too. For example, if you want to be able to get a copy event on a div, you need to turn on contentEdible
and set the tab index to -1. Strange huh?
To get around all of this, we use a hidden text input, which can of course accept these events. Then, in the before event we focus over to that hidden element. Here is an example for beforecopy:
Event.observe(document, "beforecopy", function(e) {
e.preventDefault();
$('copynpaster').focus();
});
You will notice that we make a call to preventDefault
. This tells the system that we are in control and are handling the copy ourselves. This turns out to be important is some subtle ways too. For example, The OS will often grey out a menu choice for “Copy” if it doesn’t see anything selected. Since we are in control of the selection, we need to override that behavior and continue to pass through the event. In fact, I noticed a few weird things happening if you don’t get this all just right. For example, in WebKit, if you select the “Edit” main menu, it would fire a beforecopy
right away, and then once again when you selected copy. Strange.
But, now we have gotten beforecopy
to focus us in the text input, we are ready to implement the copy event which will not get passed through. Here we need to do OUR magic to get the selection that we want to put into the clipboard, and for that we use e.clipboardData.setData(type, data)
. In our case the type used is just plain text (test/plain) but you could use other rich data formats if you need.
You will also notice at the bottom of the code, we focus back on the canvas to shoot us back to where we were. This completes the cycle:
Event.observe(document, "copy", function(e) {
var selectionText = _editor.getSelectionAsText();
if (selectionText && selectionText != '') {
e.preventDefault();
e.clipboardData.setData('text/plain', selectionText);
}
$('canvas').focus();
});
The cut action is identical bar the step that gets the data into the clipboard. In the case of cut we also add a step to delete the current selection:
_editor.ui.actions.deleteSelection(selectionObject);
Paste is also quite similar, but instead of adding to the clipboard we are getting the data out, and for that we use:
e.clipboardData.getData('text/plain');
Now we are good to go. You can see the code in its entirety here.
You may notice some of the wiring up of the two styles (Firefox/WebKit).
We cheat and do a simple test:
setup: function() {
if (Prototype.Browser.WebKit) {
this.install(new Bespin.Clipboard.DOMEvents());
} else {
this.install(new Bespin.Clipboard.Default());
}
}
We favour feature detection over browser detection of course, but here it is a little painful as the real test is to to see if the event in a copy/cut/paste/ has a clipboardData
object associated with it. That’s a little too deep into the onion layers, so we punt.
We would love to use this standards approach with Firefox too, and we almost can. MDC has docs on support for this, such as paste but the rub is in this bug that states how onpaste
doesn’t let you getData()
back, which makes it useless for all bar signaling when the user wants o paste something. PPK does a good job talking abou the various quirks with these events in general.
We still have some problems though. We handle the copy/cut/paste menus and keyboard shortcuts perfectly, but our UI has our own icons that try to kick off these actions. This is where we need to get zeroclipboard going, and then we are done (in WebKit).
Phew. That turned out to be a long explanation. Fun times on the Open Web! What am I missing? What tricks have you run into? Inquiring minds want to know!
More articles from Bespin
We are learning a lot from Bespin and beyond. Here are posts from the team and community that you may find interesting: