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 |
|
77 |
if (this.setOptions) |
78 |
this.setOptions(options); |
79 |
else |
80 |
this.options = {} |
81 |
|
82 |
this.options.tokens = this.options.tokens || new Array(); |
83 |
this.options.frequency = this.options.frequency || 0.4; |
84 |
this.options.min_chars = this.options.min_chars || 1; |
85 |
this.options.onShow = this.options.onShow || |
86 |
function(element, update){ |
87 |
if(!update.style.position || update.style.position=='absolute') { |
88 |
update.style.position = 'absolute'; |
89 |
var offsets = Position.cumulativeOffset(element); |
90 |
update.style.left = offsets[0] + 'px'; |
91 |
update.style.top = (offsets[1] + element.offsetHeight) + 'px'; |
92 |
update.style.width = element.offsetWidth + 'px'; |
93 |
} |
94 |
new Effect.Appear(update,{duration:0.15}); |
95 |
}; |
96 |
this.options.onHide = this.options.onHide || |
97 |
function(element, update){ new Effect.Fade(update,{duration:0.15}) }; |
98 |
|
99 |
if(this.options.indicator) |
100 |
this.indicator = $(this.options.indicator); |
101 |
|
102 |
if (typeof(this.options.tokens) == 'string') |
103 |
this.options.tokens = new Array(this.options.tokens); |
104 |
|
105 |
this.observer = null; |
106 |
|
107 |
Element.hide(this.update); |
108 |
|
109 |
Event.observe(this.element, "blur", this.onBlur.bindAsEventListener(this)); |
110 |
Event.observe(this.element, "keypress", this.onKeyPress.bindAsEventListener(this)); |
111 |
}, |
112 |
|
113 |
show: function() { |
114 |
if(this.update.style.display=='none') this.options.onShow(this.element, this.update); |
115 |
if(!this.iefix && (navigator.appVersion.indexOf('MSIE')>0) && this.update.style.position=='absolute') { |
116 |
new Insertion.After(this.update, |
117 |
'<iframe id="' + this.update.id + '_iefix" '+ |
118 |
'style="display:none;filter:progid:DXImageTransform.Microsoft.Alpha(opacity=0);" ' + |
119 |
'src="javascript:false;" frameborder="0" scrolling="no"></iframe>'); |
120 |
this.iefix = $(this.update.id+'_iefix'); |
121 |
} |
122 |
if(this.iefix) { |
123 |
Position.clone(this.update, this.iefix); |
124 |
this.iefix.style.zIndex = 1; |
125 |
this.update.style.zIndex = 2; |
126 |
Element.show(this.iefix); |
127 |
} |
128 |
}, |
129 |
|
130 |
hide: function() { |
131 |
if(this.update.style.display=='') this.options.onHide(this.element, this.update); |
132 |
if(this.iefix) Element.hide(this.iefix); |
133 |
}, |
134 |
|
135 |
startIndicator: function() { |
136 |
if(this.indicator) Element.show(this.indicator); |
137 |
}, |
138 |
|
139 |
stopIndicator: function() { |
140 |
if(this.indicator) Element.hide(this.indicator); |
141 |
}, |
142 |
|
143 |
onKeyPress: function(event) { |
144 |
if(this.active) |
145 |
switch(event.keyCode) { |
146 |
case Event.KEY_TAB: |
147 |
case Event.KEY_RETURN: |
148 |
this.select_entry(); |
149 |
Event.stop(event); |
150 |
case Event.KEY_ESC: |
151 |
this.hide(); |
152 |
this.active = false; |
153 |
return; |
154 |
case Event.KEY_LEFT: |
155 |
case Event.KEY_RIGHT: |
156 |
return; |
157 |
case Event.KEY_UP: |
158 |
this.mark_previous(); |
159 |
this.render(); |
160 |
if(navigator.appVersion.indexOf('AppleWebKit')>0) Event.stop(event); |
161 |
return; |
162 |
case Event.KEY_DOWN: |
163 |
this.mark_next(); |
164 |
this.render(); |
165 |
if(navigator.appVersion.indexOf('AppleWebKit')>0) Event.stop(event); |
166 |
return; |
167 |
} |
168 |
else |
169 |
if(event.keyCode==Event.KEY_TAB || event.keyCode==Event.KEY_RETURN) |
170 |
return; |
171 |
|
172 |
this.changed = true; |
173 |
this.has_focus = true; |
174 |
|
175 |
if(this.observer) clearTimeout(this.observer); |
176 |
this.observer = |
177 |
setTimeout(this.onObserverEvent.bind(this), this.options.frequency*1000); |
178 |
}, |
179 |
|
180 |
onHover: function(event) { |
181 |
var element = Event.findElement(event, 'LI'); |
182 |
if(this.index != element.autocompleteIndex) |
183 |
{ |
184 |
this.index = element.autocompleteIndex; |
185 |
this.render(); |
186 |
} |
187 |
Event.stop(event); |
188 |
}, |
189 |
|
190 |
onClick: function(event) { |
191 |
var element = Event.findElement(event, 'LI'); |
192 |
this.index = element.autocompleteIndex; |
193 |
this.select_entry(); |
194 |
Event.stop(event); |
195 |
}, |
196 |
|
197 |
onBlur: function(event) { |
198 |
// needed to make click events working |
199 |
setTimeout(this.hide.bind(this), 250); |
200 |
this.has_focus = false; |
201 |
this.active = false; |
202 |
}, |
203 |
|
204 |
render: function() { |
205 |
if(this.entry_count > 0) { |
206 |
for (var i = 0; i < this.entry_count; i++) |
207 |
this.index==i ? |
208 |
Element.addClassName(this.get_entry(i),"selected") : |
209 |
Element.removeClassName(this.get_entry(i),"selected"); |
210 |
|
211 |
if(this.has_focus) { |
212 |
if(this.get_current_entry().scrollIntoView) |
213 |
this.get_current_entry().scrollIntoView(false); |
214 |
|
215 |
this.show(); |
216 |
this.active = true; |
217 |
} |
218 |
} else this.hide(); |
219 |
}, |
220 |
|
221 |
mark_previous: function() { |
222 |
if(this.index > 0) this.index-- |
223 |
else this.index = this.entry_count-1; |
224 |
}, |
225 |
|
226 |
mark_next: function() { |
227 |
if(this.index < this.entry_count-1) this.index++ |
228 |
else this.index = 0; |
229 |
}, |
230 |
|
231 |
get_entry: function(index) { |
232 |
return this.update.firstChild.childNodes[index]; |
233 |
}, |
234 |
|
235 |
get_current_entry: function() { |
236 |
return this.get_entry(this.index); |
237 |
}, |
238 |
|
239 |
select_entry: function() { |
240 |
this.active = false; |
241 |
value = Element.collectTextNodesIgnoreClass(this.get_current_entry(), 'informal').unescapeHTML(); |
242 |
this.updateElement(value); |
243 |
this.element.focus(); |
244 |
}, |
245 |
|
246 |
updateElement: function(value) { |
247 |
var last_token_pos = this.findLastToken(); |
248 |
if (last_token_pos != -1) { |
249 |
var new_value = this.element.value.substr(0, last_token_pos + 1); |
250 |
var whitespace = this.element.value.substr(last_token_pos + 1).match(/^\s+/); |
251 |
if (whitespace) |
252 |
new_value += whitespace[0]; |
253 |
this.element.value = new_value + value; |
254 |
} else { |
255 |
this.element.value = value; |
256 |
} |
257 |
}, |
258 |
|
259 |
updateChoices: function(choices) { |
260 |
if(!this.changed && this.has_focus) { |
261 |
this.update.innerHTML = choices; |
262 |
Element.cleanWhitespace(this.update); |
263 |
Element.cleanWhitespace(this.update.firstChild); |
264 |
|
265 |
if(this.update.firstChild && this.update.firstChild.childNodes) { |
266 |
this.entry_count = |
267 |
this.update.firstChild.childNodes.length; |
268 |
for (var i = 0; i < this.entry_count; i++) { |
269 |
entry = this.get_entry(i); |
270 |
entry.autocompleteIndex = i; |
271 |
this.addObservers(entry); |
272 |
} |
273 |
} else { |
274 |
this.entry_count = 0; |
275 |
} |
276 |
|
277 |
this.stopIndicator(); |
278 |
|
279 |
this.index = 0; |
280 |
this.render(); |
281 |
} |
282 |
}, |
283 |
|
284 |
addObservers: function(element) { |
285 |
Event.observe(element, "mouseover", this.onHover.bindAsEventListener(this)); |
286 |
Event.observe(element, "click", this.onClick.bindAsEventListener(this)); |
287 |
}, |
288 |
|
289 |
onObserverEvent: function() { |
290 |
this.changed = false; |
291 |
if(this.getEntry().length>=this.options.min_chars) { |
292 |
this.startIndicator(); |
293 |
this.getUpdatedChoices(); |
294 |
} else { |
295 |
this.active = false; |
296 |
this.hide(); |
297 |
} |
298 |
}, |
299 |
|
300 |
getEntry: function() { |
301 |
var token_pos = this.findLastToken(); |
302 |
if (token_pos != -1) |
303 |
var ret = this.element.value.substr(token_pos + 1).replace(/^\s+/,'').replace(/\s+$/,''); |
304 |
else |
305 |
var ret = this.element.value; |
306 |
|
307 |
return /\n/.test(ret) ? '' : ret; |
308 |
}, |
309 |
|
310 |
findLastToken: function() { |
311 |
var last_token_pos = -1; |
312 |
|
313 |
for (var i=0; i<this.options.tokens.length; i++) { |
314 |
var this_token_pos = this.element.value.lastIndexOf(this.options.tokens[i]); |
315 |
if (this_token_pos > last_token_pos) |
316 |
last_token_pos = this_token_pos; |
317 |
} |
318 |
return last_token_pos; |
319 |
} |
320 |
} |
321 |
|
322 |
Ajax.Autocompleter = Class.create(); |
323 |
Ajax.Autocompleter.prototype = Object.extend(new Autocompleter.Base(), |
324 |
Object.extend(new Ajax.Base(), { |
325 |
initialize: function(element, update, url, options) { |
326 |
this.base_initialize(element, update, options); |
327 |
this.options.asynchronous = true; |
328 |
this.options.onComplete = this.onComplete.bind(this) |
329 |
this.options.method = 'post'; |
330 |
this.options.defaultParams = this.options.parameters || null; |
331 |
this.url = url; |
332 |
}, |
333 |
|
334 |
getUpdatedChoices: function() { |
335 |
entry = encodeURIComponent(this.element.name) + '=' + |
336 |
encodeURIComponent(this.getEntry()); |
337 |
|
338 |
this.options.parameters = this.options.callback ? |
339 |
this.options.callback(this.element, entry) : entry; |
340 |
|
341 |
if(this.options.defaultParams) |
342 |
this.options.parameters += '&' + this.options.defaultParams; |
343 |
|
344 |
new Ajax.Request(this.url, this.options); |
345 |
}, |
346 |
|
347 |
onComplete: function(request) { |
348 |
this.updateChoices(request.responseText); |
349 |
} |
350 |
|
351 |
})); |
352 |
|
353 |
// The local array autocompleter. Used when you'd prefer to |
354 |
// inject an array of autocompletion options into the page, rather |
355 |
// than sending out Ajax queries, which can be quite slow sometimes. |
356 |
// |
357 |
// The constructor takes four parameters. The first two are, as usual, |
358 |
// the id of the monitored textbox, and id of the autocompletion menu. |
359 |
// The third is the array you want to autocomplete from, and the fourth |
360 |
// is the options block. |
361 |
// |
362 |
// Extra local autocompletion options: |
363 |
// - choices - How many autocompletion choices to offer |
364 |
// |
365 |
// - partial_search - If false, the autocompleter will match entered |
366 |
// text only at the beginning of strings in the |
367 |
// autocomplete array. Defaults to true, which will |
368 |
// match text at the beginning of any *word* in the |
369 |
// strings in the autocomplete array. If you want to |
370 |
// search anywhere in the string, additionally set |
371 |
// the option full_search to true (default: off). |
372 |
// |
373 |
// - full_search - Search anywhere in autocomplete array strings. |
374 |
// |
375 |
// - partial_chars - How many characters to enter before triggering |
376 |
// a partial match (unlike min_chars, which defines |
377 |
// how many characters are required to do any match |
378 |
// at all). Defaults to 2. |
379 |
// |
380 |
// - ignore_case - Whether to ignore case when autocompleting. |
381 |
// Defaults to true. |
382 |
// |
383 |
// It's possible to pass in a custom function as the 'selector' |
384 |
// option, if you prefer to write your own autocompletion logic. |
385 |
// In that case, the other options above will not apply unless |
386 |
// you support them. |
387 |
|
388 |
Autocompleter.Local = Class.create(); |
389 |
Autocompleter.Local.prototype = Object.extend(new Autocompleter.Base(), { |
390 |
initialize: function(element, update, array, options) { |
391 |
this.base_initialize(element, update, options); |
392 |
this.options.array = array; |
393 |
}, |
394 |
|
395 |
getUpdatedChoices: function() { |
396 |
this.updateChoices(this.options.selector(this)); |
397 |
}, |
398 |
|
399 |
setOptions: function(options) { |
400 |
this.options = Object.extend({ |
401 |
choices: 10, |
402 |
partial_search: true, |
403 |
partial_chars: 2, |
404 |
ignore_case: true, |
405 |
full_search: false, |
406 |
selector: function(instance) { |
407 |
var ret = new Array(); // Beginning matches |
408 |
var partial = new Array(); // Inside matches |
409 |
var entry = instance.getEntry(); |
410 |
var count = 0; |
411 |
|
412 |
for (var i = 0; i < instance.options.array.length && |
413 |
ret.length < instance.options.choices ; i++) { |
414 |
var elem = instance.options.array[i]; |
415 |
var found_pos = instance.options.ignore_case ? |
416 |
elem.toLowerCase().indexOf(entry.toLowerCase()) : |
417 |
elem.indexOf(entry); |
418 |
|
419 |
while (found_pos != -1) { |
420 |
if (found_pos == 0 && elem.length != entry.length) { |
421 |
ret.push("<li><strong>" + elem.substr(0, entry.length) + "</strong>" + |
422 |
elem.substr(entry.length) + "</li>"); |
423 |
break; |
424 |
} else if (entry.length >= instance.options.partial_chars && |
425 |
instance.options.partial_search && found_pos != -1) { |
426 |
if (instance.options.full_search || /\s/.test(elem.substr(found_pos-1,1))) { |
427 |
partial.push("<li>" + elem.substr(0, found_pos) + "<strong>" + |
428 |
elem.substr(found_pos, entry.length) + "</strong>" + elem.substr( |
429 |
found_pos + entry.length) + "</li>"); |
430 |
break; |
431 |
} |
432 |
} |
433 |
|
434 |
found_pos = instance.options.ignore_case ? |
435 |
elem.toLowerCase().indexOf(entry.toLowerCase(), found_pos + 1) : |
436 |
elem.indexOf(entry, found_pos + 1); |
437 |
|
438 |
} |
439 |
} |
440 |
if (partial.length) |
441 |
ret = ret.concat(partial.slice(0, instance.options.choices - ret.length)) |
442 |
return "<ul>" + ret.join('') + "</ul>"; |
443 |
} |
444 |
}, options || {}); |
445 |
} |
446 |
}); |