Site update...

Posted by Steve Longdo Mon, 10 Apr 2006 16:40:00 GMT

I updated my theme a bit, this one is either going to be called "Civil War" or "Feeling Blue". It is still in progress, however, so you may observe some more changes over the next few days. The Search is back and I modified the lightbox javascript a little bit too. I have a couple new sidebars in the works. Now running Rails 1.1 and a newer version of Typo modified to use less memory.

Typo Administration: article tagging V...

Posted by Steve Longdo Sun, 19 Mar 2006 20:43:00 GMT

Originally we pursued using the Autocompleter.Local from thescript.aculo.us library. This had some promising results. However I wanted to be able to do a listing of all of the tags in a scrollable field not just the matches to what I typed. For example: I typed in 'r' and matched 'ruby' but I can also see 'sql' which might also be a good tag to add to the posting I am working on.

This led me to extend Autocompleter.Base instead and add the desired functionality myself. Adding scrolling proved a bit tricky, mainly because the offsetHeight property of the element tags always returns zero. I think this is because it is inside of an absolutely positioned DIV, but I am not a CSS guru (believe it or not:-P). I created an AdminAutotag class that is a replica of the Autocompleter.Local class. The selector function needs to be radically altered to support showing all tags and not just matches. Also in this setup it wouldn't make sense to support partial matches so we will remove that. We can still handle case sensitivity though. Since we want this to eventually to end up in Typo, instead of putting all of the JavaScript directly on the _form.rhtml partial, lets move it instead to the typo.js file in /public.
public/javascript/typo.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
AdminAutotag = Class.create();
AdminAutotag.prototype = Object.extend(new Autocompleter.Base(), {
	initialize: function(element, update, array, options) {
	  this.baseInitialize(element, update, options);
	  this.options.array = array;
	},
  getUpdatedChoices: function() {
    this.updateChoices(this.options.selector(this));
  },
  setOptions: function(options) {
    this.options = Object.extend({
      ignoreCase: true,
	/* start selector */

      selector: function(instance) {
	  var ret       = []; // Beginning matches
	  var entry     = instance.getToken();
	  var firstMatch= -1;
	  for (var i = 0; i < instance.options.array.length; i++) { 
		var wasMatch = false;
	    var elem = instance.options.array[i];
	    var foundPos = instance.options.ignoreCase ? 
	      elem.toLowerCase().indexOf(entry.toLowerCase()) : elem.indexOf(entry);

	    while (foundPos != -1) {
	      if (foundPos == 0 && elem.length != entry.length) { 
			wasMatch = true;
			if (firstMatch < 0){
			  firstMatch=i;
			  instance.index=i;
	          ret.push("<li class=\"selected\"><strong>" + elem.substr(0, entry.length) + "</strong>" + 
	              elem.substr(entry.length) + "</li>");
			} else {
		        ret.push("<li><strong>" + elem.substr(0, entry.length) + "</strong>" + 
		          elem.substr(entry.length) + "</li>");
			}
	        break;
	      }      

	      foundPos = instance.options.ignoreCase ? 
	        elem.toLowerCase().indexOf(entry.toLowerCase(), foundPos + 1) : 
	        elem.indexOf(entry, foundPos + 1);
	    }
		if(wasMatch==false){
	    	ret.push("<li>" + elem + "</li>");
		}
	  }
	  instance.element.autocompleteIndex=firstMatch;
	  instance.index=firstMatch;
	  return "<ul>" + ret.join('') + "</ul>";
	}
	//end

    }, options || {});
  }
});
Besides removing the partial matching code, the primary difference here is that we are pushing out elements whether or not they match (#43-#45). Notice also lines 47 and 48 that set our first match. This how we will keep track of where to scroll to. In fact how are we going to handle that? We will have to control the rendering of the component so we will need to override that method. Our render function will accept a parameter, unlike the base version. This is to handle the auto-scrolling via mouse pointer capability of the DIV I mentioned before. This will allow us to selectively allow scrolling, i.e. not onMouseover events. Besides removing the partial matching code, the primary difference here is that we are pushing out elements whether or not they match (#43-#45). Notice also lines 47 and 48 that set our first match. This how we will keep track of where to scroll to. In fact how are we going to handle that? We will have to control the rendering of the component so we will need to override that method. Our render function will accept a parameter, unlike the base version. This is to handle the auto-scrolling via mouse pointer capability of the DIV I mentioned before. This will allow us to selectively allow scrolling, i.e. not onMouseover events.
Inside AdminAutotag.prototype
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  render: function(scrollActive) {
	if(this.index==0 && this.options.picked!=0) this.index = this.element.autocompleteIndex;
    if(this.entryCount > 0) {
      for (var i = 0; i < this.entryCount; i++)

        i==this.index ? 
          Element.addClassName(this.getEntry(i),"selected") : 
          Element.removeClassName(this.getEntry(i),"selected");

      if(this.hasFocus) { 
        this.show();
        this.active = true;
        //scrolling for key events only

		if(scrollActive==null || scrollActive==true) {
			var scrollAmt = this.index > 0 ? (this.index * 24) - 2 : 0;
			this.update.scrollTop=scrollAmt;
		}
	  } else {
	      this.active = false;
	      this.hide();
	  }
	}
  },
Due to the aforementioned difficulty in getting the offsetHeight of a single LI in our list there is a bit of a kludge at line #14. The index of the currently selected item times twenty four minus two seemed to work well in the browsers I tested with fairly large numbers of tags. Post a comment if you know the right way to do it. Also at line #2 there is a reference to a field we haven't seen yet (this.options.picked) we need to use this field to keep up with keyboard events...which we will need to override as well. Speaking of events we need to make sure and take care of mouse events by overriding onHover:
Inside AdminAutotag.prototype
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  render: function(scrollActive) {
	if(this.index==0 && this.options.picked!=0) this.index = this.element.autocompleteIndex;
    if(this.entryCount > 0) {
      for (var i = 0; i < this.entryCount; i++)

        i==this.index ? 
          Element.addClassName(this.getEntry(i),"selected") : 
          Element.removeClassName(this.getEntry(i),"selected");

      if(this.hasFocus) { 
        this.show();
        this.active = true;
        //scrolling for key events only


		if(scrollActive==null || scrollActive==true) {
			var scrollAmt = this.index > 0 ? (this.index * 24) - 2 : 0;
			this.update.scrollTop=scrollAmt;
		}
	  } else {
	      this.active = false;
	      this.hide();
	  }
	}
  },
These are almost the same as their counterparts in Base, but the mark functions now update our this.options.picked, and our onHover passes false to the render method so as not to have you chasing the scrolling list of tags when you use the mouse. Back to our _form.rhtml for the keywords field, it can now be simplified to just this:
<p>
  <label for="article_keywords">Keywords:</label><br/>
  <%= text_field 'article', 'keywords'  %>
<div id="auto_complete"></div>
<script type="text/javascript" language="javascript" charset="utf-8">
  opts = new Array(<%= @tags.sort_by{|t| t.display_name}.collect!{|t1| '"' + t1.display_name + '",'} -%>'');
  new AdminAutotag('article_keywords','auto_complete', opts , { tokens: new Array(' ',',','\n')});
</script>
</p>
Since we use this functionality in admin, I put the CSS for the autocomplete there:
 /* public/stylesheets/administration.css */

#auto_complete {
  width: 355px;
  height: 150px;
  background: #ffffff;
  overflow: auto;
}

#auto_complete ul {
  border: 1px solid #888888;
  margin:0;
  padding:0;
  width: 98%;
  list-style-type:none;
}

#auto_complete ul li {
  margin:0;
  padding:0;
}

#auto_complete ul li.selected {
  background-color: #d7dfe4;
}
#auto_complete ul strong.highlight {
  color: #880000;
  margin:0;
  padding:0;
}

That is all there is too it. Seems simple now huh? The above code has been packed up as a patch against Typo and is detailed in ticket #727.

Typo Administration: article tagging III...

Posted by Steve Longdo Wed, 15 Mar 2006 03:47:00 GMT

Last time I alluded to using an AJAX-less solution for the tagging auto completion feature. In fact in the controls.js JavaScript file distributed with Typo there is a "class", Autocompleter.Local, that does what we are after. As the name implies it does completion from a local JavaScript array.

We need to coerce the @tags attribute we set up in the content_controller last time, into a JavaScript array. Then hook up the Autocompleter.Local to the Keywords field on the Article form and pass it the JavaScript array of tags. The Autocompleter.Local needs a <DIV> element to update with matches for what we type into the Keywords field. Also for everything to work properly, turn off the browser's native "autocomplete" feature by setting it to "off" in the text_field method's options hash for the Keywords field. Now that we know what the next steps are lets code:
app/views/admin/content/_form.rhtml
...
<p>
  <label for="article_keywords">Keywords:</label><br />
  <%= text_field 'article', 'keywords', :autocomplete => 'off'  %><br />
  <div id="completer" style="max-width: 75px; display: none; overflow: auto; border: 1px solid black; background-color: white;"></div>
<script type="text/javascript" language="javascript" charset="utf-8">
  opts = new Array(<%= @tags.sort_by{|t| t.display_name}.collect!{|t1| '"' + t1.display_name + '",'} -%>'');
// <![CDATA[  
  new Autocompleter.Local('article_keywords','completer', opts
  , { tokens: new Array(' ',',','\n'), partialChars: 1, choices: 20, partialSearch: false});
// ]]>
</script>
</p>
...
This code reads well, it is Ruby after all, but the line right after the <script> element may deserve some special explanation. That dense little line of Ruby code sorts our @tags by their display_name and then collects them together into a String that will look like this: ("tag1","tag2",...,''). This String will in turn be evaluated into a JavaScript Array. The options being passed to the Autocompleter {tokens,choices,etc.} are documented in the controls.js JavaScript file distributed with Typo.
Let's create a new article and check out the fruits of our labor thus far by seeing what happens when we enter the letter 'a':
The CSS styling provided is pulled from the admin panel's CSS stylesheet, hence the redness. Also you can see that my guess at making the completer <DIV> element's max-width: 75px, didn't work out too well. Using Chris Pedrick's WebDeveloper Extension to view the generated source of our completer <DIV> element, the HTML markup provided by the Autocompleter is pretty basic. We'll work on augmenting it and making the CSS styling better looking next time.


Sorry, for the extra long delay in finishing this up. I ran into some issues with the theme I was using and some other distractions... I will attempt to get the next update out before the weekend.
Read more...