Sunday, January 10, 2010

How to Create a Bookmarklet Like FriendFeed's

Background

I've been working on a gift web site called Gifty with my fiance, James. The site allows users to create groups, such as a family group. The user can then add their gift wishlist to the group. Other users in the group can see the person's wishlist and select which gifts to get that person.

I thought a great addition for the site would be a bookmarklet similar to FriendFeed's. The user would be able to add a gift from any site they're on. For example, if they're on the Amazon Kindle page, when they clicked on the bookmarklet, they would be able to add the Kindle to their wishlist.

Before I began, I had to figure out how FriendFeed did it. I encountered a couple surprises along the way, and thought I'd share with everyone the details of the code.

The Concept

The concept is pretty simple:
  1. Create a link that is purely a JavaScript method call, which acts as the bookmark
  2. Have this JavaScript method add a div element to any page using JavaScript
  3. Display a form in this div element
  4. Have the form submit a post to a URL on Gifty
  5. Remove the div element from the page.
Seems easy, right? Well, yes, the concept is easy, but there are a few tricky aspects that I didn't foresee. I'll go through each step, describing how I (and FriendFeed) accomplished the goal.

1. Create a link that is purely a JavaScript method call, which acts as the bookmark

This step was very simple. Drag the following link to your bookmark bar and test it out:

Bookmark

You should see a JavaScript alert pop up, while you remain on the same page. (If nothing happens, you might have JavaScript disabled.) What is the code behind this link? It's simply

 javascript:alert('hi')


Now, I wanted to do something more complex than a simple alert, of course, so I reviewed FriendFeed's bookmark JavaScript, which is as follows:

javascript:void((function(){
var%20e=document.createElement('script');
e.setAttribute('type','text/javascript');
e.setAttribute('src','http://friendfeed.com/share/bookmarklet/javascript');
document.body.appendChild(e)})())


As you can see, their function creates a new script element with their JavaScript file as the source and appends this to the page. How clever! All updates to the code can be changed in their file. As long as the link to the JavaScript file remains the same, new versions will be pushed automatically to all users.

I replaced FriendFeed's src file with my own, bookmarklet.js, and was on my way to the next step.

2. Have this javascript method add a div element to any page using javascript

Adding the div tag to the page is pretty simple, too. I applied a similar approach to FriendFeed, and used the following code:

 var container = document.createElement("div");
container.style.padding = "0";
container.style.margin = "0";
container.style.border = "1px solid #000000";
container.id = "giftybox";
container.style.position = "absolute";
container.style.top = "0";
container.style.right = "0";
container.style.zIndex = 100000;
container.style.width = "350px";
container.style.height = "210px";
container.style.backgroundColor = "white";
document.body.appendChild(container);


A new document element is created and all the style is applied to the element. Important style attributes to note are the absolute positioning and the z-index. The absolute positioning allows the element to be placed exactly where we ask it to be placed on the page, in spite of any other elements that might be in the way. The z-index means that this element should be placed at a position of 100,000 above any other elements. This takes an assumption that all other HTML elements on the page have a z-index smaller than this. If there's an element with a z-index of 100,000+, then that element will display in front of my div. However, I'm willing to take the bet that most HTML elements will not have such a large z-index, and have settled with 100K.

This JavaScript is placed in the JavaScript file mentioned above. To make sure my JavaScript didn't interfere with any other JavaScript on the page, I used namespaces. I created a new variable called giftyFunctions and set this equal to an anonymous function with the return value being a map of the function name to another anonymous function. Confused? Here's what it looks like in the code:

 var giftyFunctions = function() {
return {
addDiv : function() {
//code from above
}
}
}


Now, at the bottom of the file, I can call the function to add the div element as follows:

 giftyFunctions().addDiv();


This should, hopefully, eliminate the possibility that another function on the page would have the same name and would interfere with my function.

3. Display a form in the div element

The form was a bit trickier. The HTML had to be hosted on our own servers for two reasons:
  1. We needed to make sure the user is logged in before showing the form
  2. We needed to add user-specific data to the page (e.g., what groups they belong to)
To get around this, I looked to FriendFeed again. Again, they provided a very clever solution: they added an iframe element to the div element added earlier. The source was set to an HTML page hosted on their own servers. I applied a similar approach, creating the following HTML page:

 <body onload="addText();">
<p style="float:right;"><a href="javascript:closeBox('close');">Close</a></p>
<p><strong>Gifty</strong></p>
<form id="frameform">
<p><label for="giftDescription">Gift Name:</label> <input type="text" name="_giftDescription" id="_giftDescription" /></p>
<p><label for="giftLink">Link:</label> <input type="text" name="_giftLink" id="_giftLink" /></p>
<p><label for="groupId">Group:</label> <select name="_groupId" id ="_groupId" >
{% for group in userGroups %}
<option value={{ group.key.id }}>{{ group.groupName }}</option>
{% endfor %}
</select></p>
<input type="button" value="Submit" onclick="submitForm();" />
</form>
</body>


Note that this form does not contain an action, and the submit button is just a button with a JavaScript onclick event. This differs from FriendFeed's form, in which they use an actual submit input. I'm using a button since I want to control the submission of the form via the JavaScript function, submitForm. More about this later.

The iframe source is set to the above HTML by adding a couple lines of JavaScript to the JavaScript file:

 container.innerHTML = '<iframe style="width:100%;height:100%;border:0px;" id="giftyframe"></iframe>';
document.body.appendChild(container);
giftySetIframe();


The setIFrameLocation code is as follows:

 function setIFrameLocation() {
var iframe;
if (navigator.userAgent.indexOf("Safari") != -1) {
iframe = frames["giftyframe"];
} else {
iframe = document.getElementById("giftyframe").contentWindow;
}
if (!iframe) return;
var url = 'http://www.kathrynbrisbin.com/development/practice/frame.html'
url += '#gifty?giftname=' + document.title;
url += '&giftylocation=' + window.location.href;
try {
iframe.location.replace(url);
} catch (e) {
iframe.location = url; // safari
}
}


Note, there are two parameters added to the end of the iframe URL: giftname and giftylocation. These two parameters pass important information to the iframe so that the two input fields in the form, Gift Name and Link, can be given initial values. These are added via the addText function called on page load.

 function addText() { 
var windowLocation = window.location.href;
var params = windowLocation.split('#gifty?')[1];
var subparams = params.split('&gifty');
var title = unescape(subparams[0].split('=',2)[1]);
parentLocation = subparams[1].substring(subparams[1].indexOf('=')+1,subparams[1].length);
document.getElementById('_giftDescription').value = title;
document.getElementById('_giftLink').value = parentLocation;
}


Now that the form is added to the div, it's time to move on to the form submission.

4.
Have the form submit a post to a URL on Gifty

As I mentioned earlier, the form submission is performed via the submitForm method. This method uses a jQuery AJAX method to submit the form data via a post. The code is extremely simple, thanks to jQuery's help:

 function submitForm() {
$.post("/bookmarklet", $("#frameform").serialize());
...
}


The $.post function, a jQuery function, takes a URL and data as parameters. It makes an asynchronous call to the URL and posts the data to the URL. Since the call is asynchronous, it allows the user to remain on the current page. More information can be found on the jQuery website.

5. Remove the div element from the page.

Once the form is submitted, the div element needs to be removed from the page. To do so, control needs to return to the parent window, since the iframe itself can not remove the div element from the parent page. Unfortunately, JavaScript does easily allow for control to return to the parent. If the source of an iframe has a different domain from the parent, the parent property can not be used. So, what to do??

Again, I looked to FriendFeed to see their approach. Their solution was unique. In the initial JavaScript file, they set up an interval for a function that checks the page URL every 50 milliseconds. The function checks whether a message has been added to the end of the URL. The message begins with # + a FriendFeed specific string + =. Depending on the message, the function performs different actions, including removal of the div element.

I mimicked this design, and added the interval to my bookmarklet.js file:

 var interval = window.setInterval(function(){
giftyFrameMessage();
}, 50);


The giftyFrameMessage function finds the message at the end of the URL and sends this to another function to handle messages:

 function giftyFrameMessage(){
var gCurScroll = giftyScrollPos();
var hash = location.href.split('#');
if (hash.length > 1 && hash[hash.length - 1].match('gifty') != null) {
location.replace(hash[0] + "#");
giftySetScroll(gCurScroll);
giftyHandleMessage(hash[hash.length - 1]);
}
}


Finally, the giftyHandleMessage simply closes the div element:

 function giftyHandleMessage(msg){
giftyClose();
}
//close the box
function giftyClose(){
var giftybox = document.getElementById('giftybox');
giftybox.parentNode.removeChild(giftybox);
window.onscroll = null;
}


This function can be more complex and perform different actions based on the message. However, my code required nothing more complex at this point.

Now that I had the interval set up, I had to update the URL from the iframe. The iframe doesn't have access to the parent, but it does have access to "top". After submitting the form, I called the following method:

 function closeBox(message) {
var url = parentLocation + "#gifty=" + message;
try {
top.location.replace(url);
} catch (e) {
top.location = url;
}
}


This changes the URL, adding the message to the end. Once the giftyFrameMessage function is called, it finds the #gifty string in it, and then closes the div.

And that's the majority of the code for the FriendFeed bookmarklet! Of course there's much more to it than this, but this will help get you started!

No comments:

Post a Comment