Mar 04

Supporting the system clipboard in your Web Applications: Part Two

Bespin, JavaScript, Tech with tags: 17 Comments »

paste
Courtesy SierraBlair

I had a rather long post on the pain of dealing with the system clipboard in Web applications now that Flash went and fixed some security hole :/

The solution in place at that time worked well in Safari, with the on[cut|copy|paste] and unfortunately not great with Firefox due to one crucial big of data being omitted.

Then Tom Robinson and a couple of others made the great point that I could get ALMOST everything I wanted with just the hidden textaraea trick. Instead of tying the trick to the on[cut|copy|paste] events, I can just manually grab the Cmd/Ctrl-C X V commands. The only downside to this is that if the user goes to the Edit menu and chooses something, it won’t work. Annoying, but that’s the world of the hacky Web sometimes.

I added in this new tactic, and copy and paste works OK for Firefox in the latest version of Bespin in code (not deployed to bespin.mozilla.com at the time of this writing):

dojo.declare("bespin.util.clipboard.HiddenWorld", null, {
    install: function() {
        // * Configure the hidden copynpaster element
        var copynpaster = dojo.create("textarea", {
            tabIndex: '-1',
            autocomplete: 'off',
            id: 'copynpaster',
            style: "position: absolute; z-index: -400; top: -100px; left: -100px; width: 0; height: 0; border: none;"
        }, dojo.body());
 
        var grabAndGo = function(text) {
            copynpaster.value = text;
            focusSelectAndGo();
        };
 
        var focusSelectAndGo = function() {
            copynpaster.focus();
            copynpaster.select();
            setTimeout(function() {
                dojo.byId('canvas').focus();
            }, 0);
        };
 
        this.keyDown = dojo.connect(document, "keydown", function(e) {
            if ((bespin.util.isMac() && e.metaKey) || e.ctrlKey) {
                // Copy
                if (e.keyCode == 67 /*c*/) {
                    // place the selection into the textarea
                    var selectionText = _editor.getSelectionAsText();
 
                    if (selectionText && selectionText != '') {
                        grabAndGo(selectionText);
                    }
 
                // Cut
                } else if (e.keyCode == 88 /*x*/) {
                    // place the selection into the textarea
                    var selectionObject = _editor.getSelection();
 
                    if (selectionObject) {
                        var selectionText = _editor.model.getChunk(selectionObject);
 
                        if (selectionText && selectionText != '') {
                            grabAndGo(selectionText);
                            _editor.ui.actions.deleteSelection(selectionObject);
                        }
                    }
 
                // Paste
                } else if (e.keyCode == 86 /*v*/) {
                    focusSelectAndGo();
 
                    setTimeout(function() { // wait just a TOUCH to make sure that it is selected
                        var args = bespin.editor.utils.buildArgs();    
                        args.chunk = copynpaster.value;
                        if (args.chunk) _editor.ui.actions.insertChunk(args);
                    }, 1);
                }
            }
        });
    },
 
    uninstall: function() {
        dojo.disconnect(this.keyDown);
    }
});

Because of the issues, I took out the UI buttons for cut/copy/paste, and am in fact wondering if the editor needs that row at all. I wonder if we can consolidate the header to one line, giving us more vertical space. A code editor for developers is not like Google Docs for average Joe users, so having the visual cues probably doesn’t matter in the same way for items like copy.

There are a few subtle annoyances such as running an action like killLine (Ctrl-K) which cuts the selection but has to do so in a non-work with the clipboard way.

End result: getting there, but still need to work on making this generally viable for any application on the Open Web Platform. What do you think?

UPDATE: Used the copynpaster variable throughout, and added a setTimeout around the paste operation as I found some people needed to hit the paste key twice as the focus()/select() was taking a little too long.

Feb 26

Supporting the system clipboard in your Web applications; What a pain!

Bespin, Tech with tags: 26 Comments »

copypaste

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: