Skip to main content
Ben Nadel at BFusion / BFLEX 2009 (Bloomington, Indiana) with: Bob Burns
Ben Nadel at BFusion / BFLEX 2009 (Bloomington, Indiana) with: Bob Burns

Creating Flickr-Style Photo Tagging With jQuery

By
Published in Comments (13)

Lately, I've been experimenting with a lot mouse-move event-binding, which has led to some really cool internal stuff here at work. Building on top of some of that recent learning, this morning, I wanted to see if I could create a Flickr-style photo tagging effect using jQuery. I've never actually used the Flickr-photo tagging system, so the actually experience here is based on a lose assumption; but, I've seen the white boxes that it eventually creates, so that's where I went with this code.

In this Flickr-style effect, we start off with an image on the page. The user can then draw boxes on top of portions of that image and then leave a note as to the relevance of said box (ex. "The lighting here is amazing!"). In addition to tagging a photo the user can also mouse over existing tags to see the notes left by other users. To keep things simple for this demo, I have concentrated purely on the Javascript, leaving out all server-side code and persistence mechanisms - that can always be discussed in a follow-up post.

The HTML for this page is very basic as most of the magic happens in the Javascript files:

<!DOCTYPE HTML>
<html>
<head>
	<title>Flickr-Style Photo Tagging With jQuery</title>
	<style type="text/css">

		#photo-container {
			position: relative ;
			}

		#photo-container img {
			display: block ;
			}

		a.tag {
			border: 1px solid #FFFFFF ;
			height: 1px ;
			position: absolute ;
			width: 1px ;
			z-index: 100 ;
			}

		a.selected-tag {
			border-color: #FFFFFF ;
			z-index: 200 ;
			}

		div.hide-tags a.tag {
			display: none ;
			}

		div.tag-message {
			background-color: #212121 ;
			border: 1px solid #000000 ;
			color: #F0F0F0 ;
			display: none ;
			font-family: verdana ;
			font-size: 12px ;
			padding: 5px 10px 5px 10px ;
			position: absolute ;
			white-space: nowrap ;
			z-index: 200 ;
			}

	</style>
	<script type="text/javascript" src="../jquery-1.4.js"></script>
	<script type="text/javascript" src="./tagging.js"></script>
</head>
<body>

	<h1>
		Flickr-Style Photo Tagging With jQuery
	</h1>

	<div id="photo-container" class="hide-tags">
		<img src="./sexy.jpg" width="520" height="347" />
	</div>

</body>
</html>

As you can see, there is not much more than a containing DIV and an IMG tag. The containing DIV is important because the user-created tags will need to be absolutely positioned above the image. By using a container DIV, we can create a locally-positioned element that can act as the offset parent to the tag elements.

With these elements in place, the Javascript has to then bind a few key event listeners. On the Container, we need to listen for:

HoverIn (MouseEnter): When the user mouses into the container, we want to show the existing tags.

HoverOut (MouseLeave): When the user mouses out of the container, we want to hide the existing tags (so that the user may view the unadulterated image).

MouseDown+CTRL: When the user presses the mouse button, we want to start drawing a tag. For convenience, I have mandated that the user must hold down the CTRL key when first pressing the mouse button; I felt that this would prevent the creation of unwanted tags.

MouseUp: When the user mouses up, any pending tag (currently being drawn) needs to be finalized.

MouseMove: When the user moves their mouse, any pending tag (currently being drawn) has to be resized or have its position translated.

Because mouse-move events are fired so often, I only bind the mouse-move event listener when the user has pressed the mouse button. In this way, we know that the mouse-move event listener is only doing the heavy lifting when its role is relevant (when a new tag is being created). Similarly, when the user releases the mouse button, I am unbinding the mouse-move event listener.

To get around having to keep a reference to the event handlers themselves, I am using event name spaces to bind and unbind event handlers. Doing this allows me to make sweeping unbind() calls without having to worry about breaking any additional event bindings created outside the scope of this effect (ie. bindings not created by this process).

In addition to the container element, event listeners need to be placed on the tags as well. In this particular approach, I have chosen to explicitly bind tag-based event listeners rather than using a "live"-bind approach. Due to the large number of relevant events, the explicit nature of direct event binding just helped me keep things clear in my head. If you wanted to, there's no reason a live-binding couldn't be used.

The tags only need to know about two events:

MouseOver: When the user mouses over a given tag, I need to show the associated note as well as fade out all other tags.

MouseOut: When the user mouses out of a given tag, I need to hide the current note and bring all the tags back to their default state.

Ok, now that you see where we're going with this code, let's take a look at the Javascript:

// Create a self-executing function that will pass in a
// window object wrapper in a jquery container as well as
// the jQuery short-cut.
//
// NOTE: This will change all future references to the
// "window" object made in this scope.
;(function( window, $ ){

	// When the DOM is ready, initialize the page.
	$(function(){

		// Get references to our dom elements.
		var container = $( "#photo-container" );

		// Get a reference to our image being tagged.
		var image = container.children( "img" );

		// Get our message object (which we will add to the
		// container).
		var message = $( "<div class='tag-message'></div>" );

		// I am the collection of tags added to this photo.
		var tags = $( [] );

		// I am the pending tag - I am the one currently being
		// drawn by the user.
		var pendingTag = null;


		// Resize the container to be the dimensions of the
		// image so that we don't have any mouse confusion.
		container.width( image.width() );
		container.height( image.height() );

		// Add the message to the contianer.
		container.append( message );


		// I get the contianer-local top / left coordiantes
		// of the current mouse position based on the given page-
		// level X,Y coordinates.
		var getLocalPosition = function( mouseX, mouseY ){
			// Get the current position of the container.
			var containerOffset = container.offset();

			// Adjust the client coordiates to acocunt for
			// the offset of the page and the position of the
			// container.
			var localPosition = {
				left: Math.floor(
					mouseX - containerOffset.left + window.scrollLeft()
				),
				top: Math.floor(
					mouseY - containerOffset.top + window.scrollTop()
				)
			};

			// Return the local position of the mouse.
			return( localPosition );
		};


		// I add a pending tag at the given position and store it
		// as the global pending tag.
		var addPendingTag = function( mouseX, mouseY ){
			// Get the local position of the mouse.
			var localPosition = getLocalPosition( mouseX, mouseY );

			// Create the new tag.
			var tag = $( "<a class='tag selected-tag'><br /></a>" );

			// Set the absolute positon (within the container).
			tag.css({
				left: (localPosition.left + "px"),
				top: (localPosition.top + "px")
			});

			// Set the anchor points for the tag. This is the
			// point from which the drawing will be made
			// (regardless of technical position).
			tag.data({
				anchorLeft: localPosition.left,
				anchorTop: localPosition.top
			});

			// Set it as the pending tag.
			pendingTag = tag;

			// Add it to the container.
			container.append( pendingTag );

			// Return the new tag.
			return( pendingTag );
		};


		// I resize the pending tag based on the given mouse
		// position.
		var resizePendingTag = function( mouseX, mouseY ){
			// Get the local position of the mouse.
			var localPosition = getLocalPosition( mouseX, mouseY );

			// Get the current anchor position of the tag.
			var anchorLeft = pendingTag.data( "anchorLeft" );
			var anchorTop = pendingTag.data( "anchorTop" );

			// Get the height and width of the pending tag based
			// on its current position plus the position of the
			// mouse.We're going to allow bi-directional drawing.
			var width = Math.abs(
				(localPosition.left - anchorLeft)
			);

			var height = Math.abs(
				(localPosition.top - anchorTop)
			);

			// Set the dimensions of the tag.
			pendingTag.width( Math.max( width, 1 ) );
			pendingTag.height( Math.max( height, 1 ) );

			// Check to see if the mouse position is greater
			// than the original anchor position, the move the
			// tag (this will give us the bi-directional re-size
			// illusion).

			// Check left.
			if (localPosition.left < anchorLeft){

				// Move left.
				pendingTag.css( "left", (localPosition.left + "px") );

			}

			// Check top.
			if (localPosition.top < anchorTop){

				// Move up.
				pendingTag.css( "top", (localPosition.top + "px") );

			}
		};


		// I finalize the pending tag after the drawing has
		// stopped.
		var finalizePendingTag = function(){
			// Get the tag information from the user.
			var message = prompt( "Message:" );

			// Check to see if the message was returned.
			if (message){

				// Associate the message with the tag.
				pendingTag.data( "message", message );

				// Remove the active tag status.
				pendingTag.removeClass( "selected-tag" );

				// Remove the anchor data as it will not be used
				// again.
				pendingTag.removeData( "anchorLeft" );
				pendingTag.removeData( "anchorTop" );

				// Bind the mouse over event on this tag.
				pendingTag.bind(
					"mouseover.tag",
					onTagMouseOver
				);

				// Bind the mouse out event on this tag.
				pendingTag.bind(
					"mouseout.tag",
					onTagMouseOut
				);

				// Add this as one of the tags.
				tags = tags.add( pendingTag );

			} else {

				// No message was provided so remove the tag from
				// the container as it is of no use.
				pendingTag.remove();

			}

			// Clear the pending tag.
			pendingTag = null;
		};


		// I handle the mouse over event on the tags. We are using
		// this rather than a "live" style binding for performance.
		var onTagMouseOver = function( event ){
			// Check to see if there is a pending tag. If so, then
			// return out - we don't want to mess with that.
			if (pendingTag){
				return;
			}

			// Get the current tag.
			var tag = $( this );

			// Get teh current position of the tag.
			var tagPosition = tag.position();

			// Set the tag message.
			message.text( tag.data( "message" ) );

			// Position and show the message.
			message
				.css({
					left: (tagPosition.left + "px"),
					top: ((tagPosition.top + tag.outerHeight() + 4) + "px")
				})
				.show()
			;

			// Make this the selected tag.
			tag.addClass( "selected-tag" );

			// Dim the other tags' opacity.
			tags.css( "opacity", .25 );

			// Show the current tag.
			tag.css( "opacity", 1 );
		};


		// I handle the mouse out event on the tags. We are
		// using this rather than a "live" style binding for
		// performance.
		var onTagMouseOut = function( event ){
			// Check to see if there is a pending tag. If so,
			// then return out - we don't want to mess with that.
			if (pendingTag){
				return;
			}

			// Get the current tag.
			var tag = $( this );

			// Hide the message.
			message.hide();

			// Make sure to deselected tag.
			tag.removeClass( "selected-tag" );

			// Show all the tags.
			tags.css( "opacity", 1 );
		};


		// -------------------------------------------------- //
		// -------------------------------------------------- //


		// Bind to the hover event on the container. When the user
		// hovers over the container, we want to show the tags.
		container.hover(
			function(){
				// Show the tags be removing the "hide" class.
				container.removeClass( "hide-tags" );
			},
			function(){
				// Hide the tags by adding the "hide" class.
				container.addClass( "hide-tags" );
			}
		);


		// Bind to the mouse down even on the container.
		container.mousedown(
			function( event ){
				// Check to see if the user currently has the CTRL
				// key held down. We only want to start drawing a
				// tag IF the CTRL key is down so the user doesn't
				// start tagging the photo accidentally.
				if (event.ctrlKey){

					// The user is going to start drawing. Cancel
					// the default event to make sure the browser
					// does not try to select the IMG object.
					event.preventDefault();

					// Add the pending tag to the container.
					addPendingTag( event.clientX, event.clientY );

					// Now that we are drawing a tag, let's bind
					// the mousemove event to the container.
					container.bind(
						"mousemove.tag",
						function( event ){
							// Resize the pending tag.
							resizePendingTag(
								event.clientX,
								event.clientY
							);
						}
					);

					// Now that we have started drawing, we're
					// going to need a way to STOP drawing. If
					// the user mouses-up, then finalize drawing.
					container.bind(
						"mouseup.tag",
						function(){
							// Unbinde any mouse up and mouse move
							// events related to tagging.
							container.unbind( "mouseup.tag" );
							container.unbind( "mousemove.tag" );

							// Finalize the pending tag.
							finalizePendingTag();
						}
					);

				}
			}
		);

	});

})( jQuery( window ), jQuery );

While this might seem like a lot of code (I know my code-formatter sucks for Javascript), it's only about four functions and four event bindings. When I was writing this, the trickiest thing I came across was the translation of the mouse coordinates. When a mouse-related event fires, its coordinates are relative to the screen, not to the element with the current event binding. As such, before they could be used effectively, the mouse coordinates need to be translated from the global stage down to the local stage, taking the window offset and current element's position into account.

Explorations like this are really helping me to become more comfortable with mouse-base events. Obviously, there's a lot more that you can do with this effect, including, but not limited to, server-side interaction; this was only meant as an exploration, not a completed effect. Perhaps I'll beef it up for a follow-up post. All in all, this was just a lot of fun. jQuery is so freakin' empowering, it makes me feel tingly.

Want to use code from this post? Check out the license.

Reader Comments

6 Comments

Very cool post, I look forward to the follow up post with server side interaction. Your videos really add to these posts as well, thanks for sharing so much with the community!

15,880 Comments

@John,

Glad you like; I've been getting some great feedback about the videos lately, which is awesome. Reading code is good; but, I think it only works *after* you see the big picture and I think that's where the video comes into play.

4 Comments

Couldn't agree more about the video's!! They absolutely help put the code into proper context, and give a point of reference before leaping into a sea of scripting... makes all the difference in the world...

And this is exceptionally cool... not just in terms of developing it into a full fledged tagging plugin, but stuff like this really helps push me into seeing the possibilities with jQuery beyond just making stuff slide in and out and change color...

Gotta echo Adam's thanks for everything you do for the development community... makes all the difference in the world for some of us!

15,880 Comments

@Ryan,

Thanks a lot my man :) I'm glad that you're liking the videos and the code. jQuery is really awesome as well, and if I can pass that on in any way, my job here is done!

1 Comments

Hey Ben, i keep getting 2 alert box errors reading "There was a problem with the API." Any idea what's going on? thanks!

--SAM

2 Comments

Hi Ben,
Your plugin is great and works perfectly even in IE6, the only problem I can't overcome is to create something better than prompt. Is it possible to change variable "message" and its content outside the plugin? For example assign it to a text input.
Thanks

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel