Facebook style photo tagging with jQuery, Ajax and Joomla

A demo of the functionality can be found in one of paceville.com’s galleries. Just click the first thumbnail. Update: To see the full images one now has to be registered so the demo is not easily accessible anymore.


So we need photo tagging with the following requirements except that we need to be able to save the position of people in a picture, not just the fact that they are in the picture:
1.) You can only tag your friends.
2.) If a person removes a tag of him/herself then only an admin can retag that person.
3.) If a person is being retagged only the position of that person changes.

I won’t do any HMTL listings here, check the above link instead. Something much more important is the jQuery JS, let’s get to it:

function setupMarker(){
	$("a[id^='tag-']").unbind();
	$("a[id^='tag-']").mouseenter(function(){
		offset = $("#main_image").offset();
		var pcs = this.id.split('-');
		var xpos = parseInt(pcs[1]) + offset.left;
		var ypos = parseInt(pcs[2]) + offset.top;
		jQuery("#square_mark").show(0).css({top: ypos, left: xpos});
	});

	$("a[id^='tag-']").mouseleave(function(){
		$("#square_mark").hide(0);
	});
}

This function will be executed the first time when the document loads. We’re having a list of anchors with the names of people who are tagged in the picture, (selector: a[id^=’tag-‘]). And a div, (selector: #square_mark) that will move to the place where a person has been tagged. When the mouse cursor enters a person’s name the following will happen:

1.) We set a gobal variable, offset by retrieving the position of the image containing the tags.
2.) We then split the id (by hyphen) of the anchor we’re currently hovering over, the x-position will be in slot 1 of the resultant array, and the y-position will be in the second.
3.) The reason we then add the the coordinates of the image to the coordinates parsed from the anchor tag id is that it would be stupid to save the coordinates of the tag in an absolute way. The position of the image could change due to a re-design for instance, if that would happen the tag position would be off, not good at all.
4.) Finally we move the square mark by way of setting its style attribute.

Note how I’m fetching the offset of the image on every on mouse enter trigger when I really shouldn’t have to. I don’t know why but the square marker wouldn’t end up in the right place the first time I loaded the page if I reloaded the page it would work, really strange behavior but it disappeared when I started fetching the offset on each mouse enter, like above.

When we leave a person’s name we simply hide the square instantly.

When someone wants to start tagging he will click a link that will pass the current image id to this JavaScript function (available in the PHP rendering the HTML).

function showTagForm(img_id){
	var frm = $("#cWindow");
	frm.show(0);
	var inpt = $("#user_search");
	inpt.keydown(function(event){
		if(inpt.val().length > 3){
			$.post("/ajax.php", {func: 'jsonFriendListByPartial', name: inpt.val()}, function(res){
				res = eval( '(' + res + ')' );
				str = '';
				$.each(res, function(){
					str += '<div style="cursor:pointer;" onclick="javascript:updateTags(' + this.id + ', ' + img_id + ', \'' + this.name + '\')" >' 
					+ '<img src="/'+this.thumb+'" width="20" height="20">'
					+ this.name 
					+ '</div>';
				});
				$("#result").html(str);
			});
		}
	});

	$("#cwin_close_btn").click(cWindowHide);
}

function cWindowHide(){
	$("#cWindow").hide(0);
}

Here I’m using the HTML of the JomSocial “popup” window, it’s not a popup window but a DIV that shows up with its own custom close button.

In this window there is an input field (“#user_search”), whenever it receives keydown we will check if its value string is above a certain length (to prevent too many hits). All friends whose names substring match the contents of the input field will be displayed.

When a name is clicked we call updateTags():

function updateTags(usr_id, img_id, usr_name){
	$.post("/ajax.php", {func: 'updateTaggedUsers', "img_id": img_id, "usr_id": usr_id, "xloc": cXpos, "yloc": cYpos}, function(ret){
		if(ret == ""){ alert("The person does not want to be tagged in this image."); return; }
		var regex = new RegExp('-'+usr_id+'"', 'igm');
		var new_id = 'tag-'+cXpos+'-'+cYpos+'-'+usr_id;
		var link_parent = $("#tagged_users");
		
		if(link_parent.html().search(regex) == -1){
			if(link_parent.find("a").size() == 0)
				link_parent.append("In this picture: ");
			link_parent.append(', <a id="'+new_id+'" onclick="openInParent(\'/community/profile?userid='+usr_id+'\')" href="#">'+ ret +'</a>');
		}else{
			link_parent.find("a").each(function(){
				if($(this).attr("id").split('-').pop() == usr_id)
					$(this).attr("id", new_id);
			});
			alert("The position of "+ret+" has been changed.");
		}
		setupMarker();
		
	});
}

The main thing here is that we make use of our previously set globals, cXpos and cYpos to determine where the person is in the picture.

We start off with an Ajax call to save the tag info in the database, if the returned value is null it means the person has been tagged in the current picture at some earlier point but has removed the tag, which in turn means that person can’t be tagged in this picture again.

If the person was successfully tagged we will get his name as the return value and we begin with creating a regular expression (by way of the RegExp constructor since the regex itself will contain a variable), a hyphen followed by that person’s id, followed by a double quote (the double quote that ends the id attribute of the anchor). Next we construct a new id for that person’s anchor tag, this id will be used regardless if that person was tagged in the picture earlier or not. If the person was already tagged it will replace the old id of that person’s anchor tag.

Anyway, if we can’t find the regular expression in the anchors we will create a new anchor with the information we now have. If we can find it we find the anchor in question and replace its id.

Finally we have to call setupMarker() again to setup the new coordinates.

A person can also remove his/her tag, which is easy:

function removeTag(img_id){
	$.post("/ajax.php", {func: 'updateTaggedUsers', "img_id": img_id, remove: true}, function(ret){
		window.location.reload();
	});
}
function showTagAim(){
	var img = $("#main_image");
	img.css("cursor", "crosshair");
	img.mousemove(function(e){
		offset = $("#main_image").offset();
		mXpos = e.pageX - 20 - offset.left;
		mYpos = e.pageY - 20 - offset.top;
	});
	
	img.click(function(){
		cXpos = Math.round(mXpos);
		cYpos = Math.round(mYpos);
		offset = $("#main_image").offset();
		$("#square_mark").show(0).css({top: cYpos+offset.top, left: cXpos+offset.left});
		showTagForm(<?php echo $cur_img->id ?>);
	});
}

Two things are happening here, we keep track of where the mouse is, and once the main image is clicked we use that information to position the marker.

I was having some problems with the marker showing up in the wrong place, it went away though after I check the main image offset every time the mouse position changes, no idea why, it should be enough to check the offset once when the page loads.

Let’s take a look at the PHP:

function updateTaggedUsers(){
  	if(!$this->isGuest()){
  		$usr_id = empty($this->postArr['usr_id']) ? $this->getUser()->id : $this->postArr['usr_id'];
	  	if(!empty($this->postArr['remove'])){
	  		if($this->getUser()->id == $usr_id)
	  			$this->exec("UPDATE `jos_img_usr` SET removed = 1 WHERE `usr_id` = $usr_id AND `img_id` = {$this->postArr['img_id']}");
	  		else{
	  			$this->exec("DELETE FROM `jos_img_usr` WHERE `usr_id` = $usr_id AND `img_id` = {$this->postArr['img_id']}");
	  			$this->saveTagActivity($this->getUser()->id, $usr_id, $this->postArr['img_id'], true);
	  		}
	  		echo "";
	  	}else{
	  		$removed = $this->loadResult("SELECT removed FROM `jos_img_usr` WHERE `usr_id` = $usr_id AND `img_id` = {$this->postArr['img_id']}");
	  		if($removed == 0 || $this->getUser()->id == $usr_id){
	  			$this->postArr['removed'] = 0;
	  			$this->postArr['xloc'] = round($this->postArr['xloc']);
	  			$this->postArr['yloc'] = round($this->postArr['yloc']);
		  		$keys = array("img_id", "usr_id", "xloc", "yloc", "removed");
		  		$this->updateObject('jos_img_usr', $keys, null, null, true);
		  		$this->saveTagActivity($this->getUser()->id, $usr_id, $this->postArr['img_id']);
		  		echo $this->getUser($usr_id)->name;
	  		}else
	  			echo "";
	  	}
  	}
  }

First we check if the person trying to do the tagging is only a guest or not, if not we continue.

If the post variable “remove” is empty we have to add/change a tag, if not we have to remove a tag. If the person doing the removing is also the person who is logged in we will simply set the “removed” column to 1 which will work as a kind of lock, the tag will simply not be displayed and its existence will block further attempts at tagging.

If it’s a different person doing the removal (an admin) we simply delete it from the database. Note here how a person can’t tag the other person if the removed column has been set to 1.

Adding/Updating is made through the SQL command REPLACE INTO. Finally we call saveTagActivity which will update the activity stream and add an entry to the mail queue.

For replace into to work we need a unique index on the connection between person and image, this is what the SQL for the table looks like:

CREATE TABLE IF NOT EXISTS `jos_img_usr` (
  `usr_id` bigint(21) NOT NULL,
  `img_id` bigint(21) NOT NULL,
  `xloc` int(5) NOT NULL,
  `yloc` int(5) NOT NULL,
  `removed` tinyint(1) NOT NULL default '0',
  UNIQUE KEY `conn_idx` (`usr_id`,`img_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

Related Posts

Tags: , , ,