1 |
// Copyright (c) 2005 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us) |
2 |
// (c) 2005 Ivan Krstic (http://blogs.law.harvard.edu/ivan) |
3 |
// |
4 |
// Permission is hereby granted, free of charge, to any person obtaining |
5 |
// a copy of this software and associated documentation files (the |
6 |
// "Software"), to deal in the Software without restriction, including |
7 |
// without limitation the rights to use, copy, modify, merge, publish, |
8 |
// distribute, sublicense, and/or sell copies of the Software, and to |
9 |
// permit persons to whom the Software is furnished to do so, subject to |
10 |
// the following conditions: |
11 |
// |
12 |
// The above copyright notice and this permission notice shall be |
13 |
// included in all copies or substantial portions of the Software. |
14 |
// |
15 |
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
16 |
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
17 |
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
18 |
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE |
19 |
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION |
20 |
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION |
21 |
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
22 |
|
23 |
Element.collectTextNodesIgnoreClass = function(element, ignoreclass) { |
24 |
var children = $(element).childNodes; |
25 |
var text = ""; |
26 |
var classtest = new RegExp("^([^ ]+ )*" + ignoreclass+ "( [^ ]+)*$","i"); |
27 |
|
28 |
for (var i = 0; i < children.length; i++) { |
29 |
if(children[i].nodeType==3) { |
30 |
text+=children[i].nodeValue; |
31 |
} else { |
32 |
if((!children[i].className.match(classtest)) && children[i].hasChildNodes()) |
33 |
text += Element.collectTextNodesIgnoreClass(children[i], ignoreclass); |
34 |
} |
35 |
} |
36 |
|
37 |
return text; |
38 |
} |
39 |
|
40 |
// Autocompleter.Base handles all the autocompletion functionality |
41 |
// that's independent of the data source for autocompletion. This |
42 |
// includes drawing the autocompletion menu, observing keyboard |
43 |
// and mouse events, and similar. |
44 |
// |
45 |
// Specific autocompleters need to provide, at the very least, |
46 |
// a getUpdatedChoices function that will be invoked every time |
47 |
// the text inside the monitored textbox changes. This method |
48 |
// should get the text for which to provide autocompletion by |
49 |
// invoking this.getEntry(), NOT by directly accessing |
50 |
// this.element.value. This is to allow incremental tokenized |
51 |
// autocompletion. Specific auto-completion logic (AJAX, etc) |
52 |
// belongs in getUpdatedChoices. |
53 |
// |
54 |
// Tokenized incremental autocompletion is enabled automatically |
55 |
// when an autocompleter is instantiated with the 'tokens' option |
56 |
// in the options parameter, e.g.: |
57 |
// new Ajax.Autocompleter('id','upd', '/url/', { tokens: ',' }); |
58 |
// will incrementally autocomplete with a comma as the token. |
59 |
// Additionally, ',' in the above example can be replaced with |
60 |
// a token array, e.g. { tokens: new Array (',', '\n') } which |
61 |
// enables autocompletion on multiple tokens. This is most |
62 |
// useful when one of the tokens is \n (a newline), as it |
63 |
// allows smart autocompletion after linebreaks. |
64 |
|
65 |
var Autocompleter = {} |
66 |
Autocompleter.Base = function() {}; |
67 |
Autocompleter.Base.prototype = { |
68 |
base_initialize: function(element, update, options) { |
69 |
this.element = $(element); |
70 |
this.update = $(update); |
71 |
this.has_focus = false; |
72 |
this.changed = false; |
73 |
this.active = false; |
74 |
this.index = 0; |
75 |
this.entry_count = 0; |
76 |
this.in_dropdown = false; |
77 |
|
78 |
if (this.setOptions) |
79 |
this.setOptions(options); |
80 |
else |
81 |
this.options = {} |
82 |
|
83 |
this.options.tokens = this.options.tokens || new Array(); |
84 |
this.options.frequency = this.options.frequency || 0.4; |
85 |
this.options.min_chars = this.options.min_chars || 1; |
86 |
this.options.onShow = this.options.onShow || |
87 |
function(element, update){ |
88 |
if(!update.style.position || update.style.position=='absolute') { |
89 |
update.style.position = 'absolute'; |
90 |
var offsets = Position.cumulativeOffset(element); |
91 |
update.style.left = offsets[0] + 'px'; |
92 |
update.style.top = (offsets[1] + element.offsetHeight) + 'px'; |
93 |
update.style.width = element.offsetWidth + 'px'; |
94 |
} |
95 |
new Effect.Appear(update,{duration:0.15}); |
96 |
}; |
97 |
this.options.onHide = this.options.onHide || |
98 |
function(element, update){ new Effect.Fade(update,{duration:0.15}) }; |
99 |
|
100 |
if(this.options.indicator) |
101 |
this.indicator = $(this.options.indicator); |
102 |
|
103 |
if (typeof(this.options.tokens) == 'string') |
104 |
this.options.tokens = new Array(this.options.tokens); |
105 |
|
106 |
this.observer = null; |
107 |
|
108 |
Element.hide(this.update); |
109 |
|
110 |
Event.observe(this.element, "blur", this.onBlur.bindAsEventListener(this)); |
111 |
Event.observe(this.element, "keypress", this.onKeyPress.bindAsEventListener(this)); |
112 |
}, |
113 |
|
114 |
show: function() { |
115 |
if(this.update.style.display=='none') this.options.onShow(this.element, this.update); |
116 |
if(!this.iefix && (navigator.appVersion.indexOf('MSIE')>0) && this.update.style.position=='absolute') { |
117 |
new Insertion.After(this.update, |
118 |
'<iframe id="' + this.update.id + '_iefix" '+ |
119 |
'style="display:none;filter:progid:DXImageTransform.Microsoft.Alpha(opacity=0);" ' + |
120 |
'src="javascript:false;" frameborder="0" scrolling="no"></iframe>'); |
121 |
this.iefix = $(this.update.id+'_iefix'); |
122 |
} |
123 |
if(this.iefix) { |
124 |
Position.clone(this.update, this.iefix); |
125 |
this.iefix.style.zIndex = 1; |
126 |
this.update.style.zIndex = 2; |
127 |
Element.show(this.iefix); |
128 |
} |
129 |
}, |
130 |
|
131 |
hide: function() { |
132 |
if(this.update.style.display=='') this.options.onHide(this.element, this.update); |
133 |
if(this.iefix) Element.hide(this.iefix); |
134 |
}, |
135 |
|
136 |
startIndicator: function() { |
137 |
if(this.indicator) Element.show(this.indicator); |
138 |
}, |
139 |
|
140 |
stopIndicator: function() { |
141 |
if(this.indicator) Element.hide(this.indicator); |
142 |
}, |
143 |
|
144 |
onKeyPress: function(event) { |
145 |
if(this.active) |
146 |
switch(event.keyCode) { |
147 |
case Event.KEY_TAB: |
148 |
case Event.KEY_RETURN: |
149 |
if (this.in_dropdown) this.select_entry(); |
150 |
Event.stop(event); |
151 |
case Event.KEY_ESC: |
152 |
this.hide(); |
153 |
this.active = false; |
154 |
return; |
155 |
case Event.KEY_LEFT: |
156 |
case Event.KEY_RIGHT: |
157 |
return; |
158 |
case Event.KEY_UP: |
159 |
this.mark_previous(); |
160 |
this.render(); |
161 |
if(navigator.appVersion.indexOf('AppleWebKit')>0) Event.stop(event); |
162 |
return; |
163 |
case Event.KEY_DOWN: |
164 |
this.mark_next(); |
165 |
this.render(); |
166 |
if(navigator.appVersion.indexOf('AppleWebKit')>0) Event.stop(event); |
167 |
return; |
168 |
default: |
169 |
this.in_dropdown = false; |
170 |
} |
171 |
else |
172 |
if(event.keyCode==Event.KEY_TAB || event.keyCode==Event.KEY_RETURN) |
173 |
return; |
174 |
|
175 |
this.changed = true; |
176 |
this.has_focus = true; |
177 |
|
178 |
if(this.observer) clearTimeout(this.observer); |
179 |
this.observer = |
180 |
setTimeout(this.onObserverEvent.bind(this), this.options.frequency*1000); |
181 |
}, |
182 |
|
183 |
onHover: function(event) { |
184 |
var element = Event.findElement(event, 'LI'); |
185 |
if(this.index != element.autocompleteIndex) |
186 |
{ |
187 |
this.index = element.autocompleteIndex; |
188 |
this.render(); |
189 |
} |
190 |
Event.stop(event); |
191 |
}, |
192 |
|
193 |
onClick: function(event) { |
194 |
var element = Event.findElement(event, 'LI'); |
195 |
this.index = element.autocompleteIndex; |
196 |
this.select_entry(); |
197 |
Event.stop(event); |
198 |
}, |
199 |
|
200 |
onBlur: function(event) { |
201 |
// needed to make click events working |
202 |
setTimeout(this.hide.bind(this), 250); |
203 |
this.has_focus = false; |
204 |
this.active = false; |
205 |
}, |
206 |
|
207 |
render: function() { |
208 |
if(this.entry_count > 0) { |
209 |
for (var i = 0; i < this.entry_count; i++) |
210 |
this.index==i && this.in_dropdown ? |
211 |
Element.addClassName(this.get_entry(i),"selected") : |
212 |
Element.removeClassName(this.get_entry(i),"selected"); |
213 |
|
214 |
if(this.has_focus) { |
215 |
if(this.get_current_entry().scrollIntoView) |
216 |
this.get_current_entry().scrollIntoView(false); |
217 |
|
218 |
this.show(); |
219 |
this.active = true; |
220 |
} |
221 |
} else this.hide(); |
222 |
}, |
223 |
|
224 |
mark_previous: function() { |
225 |
if(this.index > 0) this.index-- |
226 |
else this.index = this.entry_count-1; |
227 |
this.in_dropdown = true; |
228 |
}, |
229 |
|
230 |
mark_next: function() { |
231 |
if(this.index < this.entry_count-1) this.index++ |
232 |
else this.index = 0; |
233 |
if (! this.in_dropdown) this.index = 0; |
234 |
this.in_dropdown = true; |
235 |
}, |
236 |
|
237 |
get_entry: function(index) { |
238 |
return this.update.firstChild.childNodes[index]; |
239 |
}, |
240 |
|
241 |
get_current_entry: function() { |
242 |
return this.get_entry(this.index); |
243 |
}, |
244 |
|
245 |
select_entry: function() { |
246 |
this.active = false; |
247 |
value = Element.collectTextNodesIgnoreClass(this.get_current_entry(), 'informal').unescapeHTML(); |
248 |
this.updateElement(value); |
249 |
this.element.focus(); |
250 |
}, |
251 |
|
252 |
updateElement: function(value) { |
253 |
var last_token_pos = this.findLastToken(); |
254 |
if (last_token_pos != -1) { |
255 |
var new_value = this.element.value.substr(0, last_token_pos + 1); |
256 |
var whitespace = this.element.value.substr(last_token_pos + 1).match(/^\s+/); |
257 |
if (whitespace) |
258 |
new_value += whitespace[0]; |
259 |
this.element.value = new_value + value; |
260 |
} else { |
261 |
this.element.value = value; |
262 |
} |
263 |
}, |
264 |
|
265 |
updateChoices: function(choices) { |
266 |
if(!this.changed && this.has_focus) { |
267 |
this.update.innerHTML = choices; |
268 |
Element.cleanWhitespace(this.update); |
269 |
Element.cleanWhitespace(this.update.firstChild); |
270 |
|
271 |
if(this.update.firstChild && this.update.firstChild.childNodes) { |
272 |
this.entry_count = |
273 |
this.update.firstChild.childNodes.length; |
274 |
for (var i = 0; i < this.entry_count; i++) { |
275 |
entry = this.get_entry(i); |
276 |
entry.autocompleteIndex = i; |
277 |
this.addObservers(entry); |
278 |
} |
279 |
} else { |
280 |
this.entry_count = 0; |
281 |
} |
282 |
|
283 |
this.stopIndicator(); |
284 |
|
285 |
this.index = 0; |
286 |
this.render(); |
287 |
} |
288 |
}, |
289 |
|
290 |
addObservers: function(element) { |
291 |
Event.observe(element, "mouseover", this.onHover.bindAsEventListener(this)); |
292 |
Event.observe(element, "click", this.onClick.bindAsEventListener(this)); |
293 |
}, |
294 |
|
295 |
onObserverEvent: function() { |
296 |
this.changed = false; |
297 |
if(this.getEntry().length>=this.options.min_chars) { |
298 |
this.startIndicator(); |
299 |
this.getUpdatedChoices(); |
300 |
} else { |
301 |
this.active = false; |
302 |
this.hide(); |
303 |
} |
304 |
}, |
305 |
|
306 |
getEntry: function() { |
307 |
var token_pos = this.findLastToken(); |
308 |
if (token_pos != -1) |
309 |
var ret = this.element.value.substr(token_pos + 1).replace(/^\s+/,'').replace(/\s+$/,''); |
310 |
else |
311 |
var ret = this.element.value; |
312 |
|
313 |
return /\n/.test(ret) ? '' : ret; |
314 |
}, |
315 |
|
316 |
findLastToken: function() { |
317 |
var last_token_pos = -1; |
318 |
|
319 |
for (var i=0; i<this.options.tokens.length; i++) { |
320 |
var this_token_pos = this.element.value.lastIndexOf(this.options.tokens[i]); |
321 |
if (this_token_pos > last_token_pos) |
322 |
last_token_pos = this_token_pos; |
323 |
} |
324 |
return last_token_pos; |
325 |
} |
326 |
} |
327 |
|
328 |
Ajax.Autocompleter = Class.create(); |
329 |
Ajax.Autocompleter.prototype = Object.extend(new Autocompleter.Base(), |
330 |
Object.extend(new Ajax.Base(), { |
331 |
initialize: function(element, update, url, options) { |
332 |
this.base_initialize(element, update, options); |
333 |
this.options.asynchronous = true; |
334 |
this.options.onComplete = this.onComplete.bind(this) |
335 |
this.options.method = 'post'; |
336 |
this.options.defaultParams = this.options.parameters || null; |
337 |
this.url = url; |
338 |
}, |
339 |
|
340 |
getUpdatedChoices: function() { |
341 |
entry = encodeURIComponent(this.element.name) + '=' + |
342 |
encodeURIComponent(this.getEntry()); |
343 |
|
344 |
this.options.parameters = this.options.callback ? |
345 |
this.options.callback(this.element, entry) : entry; |
346 |
|
347 |
if(this.options.defaultParams) |
348 |
this.options.parameters += '&' + this.options.defaultParams; |
349 |
|
350 |
new Ajax.Request(this.url, this.options); |
351 |
}, |
352 |
|
353 |
onComplete: function(request) { |
354 |
this.updateChoices(request.responseText); |
355 |
} |
356 |
|
357 |
})); |
358 |
|
359 |
// The local array autocompleter. Used when you'd prefer to |
360 |
// inject an array of autocompletion options into the page, rather |
361 |
// than sending out Ajax queries, which can be quite slow sometimes. |
362 |
// |
363 |
// The constructor takes four parameters. The first two are, as usual, |
364 |
// the id of the monitored textbox, and id of the autocompletion menu. |
365 |
// The third is the array you want to autocomplete from, and the fourth |
366 |
// is the options block. |
367 |
// |
368 |
// Extra local autocompletion options: |
369 |
// - choices - How many autocompletion choices to offer |
370 |
// |
371 |
// - partial_search - If false, the autocompleter will match entered |
372 |
// text only at the beginning of strings in the |
373 |
// autocomplete array. Defaults to true, which will |
374 |
// match text at the beginning of any *word* in the |
375 |
// strings in the autocomplete array. If you want to |
376 |
// search anywhere in the string, additionally set |
377 |
// the option full_search to true (default: off). |
378 |
// |
379 |
// - full_search - Search anywhere in autocomplete array strings. |
380 |
// |
381 |
// - partial_chars - How many characters to enter before triggering |
382 |
// a partial match (unlike min_chars, which defines |
383 |
// how many characters are required to do any match |
384 |
// at all). Defaults to 2. |
385 |
// |
386 |
// - ignore_case - Whether to ignore case when autocompleting. |
387 |
// Defaults to true. |
388 |
// |
389 |
// It's possible to pass in a custom function as the 'selector' |
390 |
// option, if you prefer to write your own autocompletion logic. |
391 |
// In that case, the other options above will not apply unless |
392 |
// you support them. |
393 |
|
394 |
Autocompleter.Local = Class.create(); |
395 |
Autocompleter.Local.prototype = Object.extend(new Autocompleter.Base(), { |
396 |
initialize: function(element, update, array, options) { |
397 |
this.base_initialize(element, update, options); |
398 |
this.options.array = array; |
399 |
}, |
400 |
|
401 |
getUpdatedChoices: function() { |
402 |
this.updateChoices(this.options.selector(this)); |
403 |
}, |
404 |
|
405 |
setOptions: function(options) { |
406 |
this.options = Object.extend({ |
407 |
choices: 10, |
408 |
partial_search: true, |
409 |
partial_chars: 2, |
410 |
ignore_case: true, |
411 |
full_search: false, |
412 |
selector: function(instance) { |
413 |
var ret = new Array(); // Beginning matches |
414 |
var partial = new Array(); // Inside matches |
415 |
var entry = instance.getEntry(); |
416 |
var count = 0; |
417 |
|
418 |
for (var i = 0; i < instance.options.array.length && |
419 |
ret.length < instance.options.choices ; i++) { |
420 |
var elem = instance.options.array[i]; |
421 |
var found_pos = instance.options.ignore_case ? |
422 |
elem.toLowerCase().indexOf(entry.toLowerCase()) : |
423 |
elem.indexOf(entry); |
424 |
|
425 |
while (found_pos != -1) { |
426 |
if (found_pos == 0 && elem.length != entry.length) { |
427 |
ret.push("<li><strong>" + elem.substr(0, entry.length) + "</strong>" + |
428 |
elem.substr(entry.length) + "</li>"); |
429 |
break; |
430 |
} else if (entry.length >= instance.options.partial_chars && |
431 |
instance.options.partial_search && found_pos != -1) { |
432 |
if (instance.options.full_search || /\s/.test(elem.substr(found_pos-1,1))) { |
433 |
partial.push("<li>" + elem.substr(0, found_pos) + "<strong>" + |
434 |
elem.substr(found_pos, entry.length) + "</strong>" + elem.substr( |
435 |
found_pos + entry.length) + "</li>"); |
436 |
break; |
437 |
} |
438 |
} |
439 |
|
440 |
found_pos = instance.options.ignore_case ? |
441 |
elem.toLowerCase().indexOf(entry.toLowerCase(), found_pos + 1) : |
442 |
elem.indexOf(entry, found_pos + 1); |
443 |
|
444 |
} |
445 |
} |
446 |
if (partial.length) |
447 |
ret = ret.concat(partial.slice(0, instance.options.choices - ret.length)) |
448 |
return "<ul>" + ret.join('') + "</ul>"; |
449 |
} |
450 |
}, options || {}); |
451 |
} |
452 |
}); |