root/trunk/inputcomplete.js

Revision 268, 12.6 kB (checked in by janine, 12 months ago)

LJSV-189

Make sure that the strings being compared are actually strings before performing toLowerCase() on them. Fixes bug where a tag with all numbers in it would break tag auto-completion.

  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
Line 
1/* input completion library */
2
3/* TODO:
4    -- test on non-US keyboard layouts (too much use of KeyCode)
5    -- lazy data model (xmlhttprequest, or generic callbacks)
6    -- drop-down menu?
7    -- option to disable comma-separated mode (or explicitly ask for it)
8*/
9
10/*
11  Copyright (c) 2005, Six Apart, Ltd.
12  All rights reserved.
13
14  Redistribution and use in source and binary forms, with or without
15  modification, are permitted provided that the following conditions are
16  met:
17
18  * Redistributions of source code must retain the above copyright
19  notice, this list of conditions and the following disclaimer.
20
21  * Redistributions in binary form must reproduce the above
22  copyright notice, this list of conditions and the following disclaimer
23  in the documentation and/or other materials provided with the
24  distribution.
25
26  * Neither the name of "Six Apart" nor the names of its
27  contributors may be used to endorse or promote products derived from
28  this software without specific prior written permission.
29
30  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
31  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
32  LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
33  A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
34  OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
35  SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
36  LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
37  DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
38  THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
39  (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
40  OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
41
42*/
43
44
45/* ***************************************************************************
46
47  Class: InputCompleteData
48
49  About: An InputComplete object needs a data source to auto-complete
50          from.  This is that model.  You can create one from an
51          array, or create a lazy version that gets its data over the
52          network, on demand.  You will probably not use this class'
53          methods directly, as they're called by the InputComplete
54          object.
55
56          The closer a word is to the beginning of the array, the more
57          likely it will be recommended as the word the user is typing.
58
59          If you pass the string "ignorecase" as the second argument in
60          the constructor, then the case of both the user's input and
61          the data in the array will be ignored when looking for a match.
62
63  Constructor:
64
65    var model = new InputCompleteData ([ "foo", "bar", "alpha" ]);
66
67*************************************************************************** */
68
69var InputCompleteData = new Class ( Object, {
70    init: function () {
71        if (arguments[0] instanceof Array) {
72            this.source = [];
73
74            // copy the user-provided array (which is sorted most
75            // likely to least likely) into our internal form, which
76            // is opposite, with most likely at the end.
77            var arg = arguments[0];
78            for (var i=arg.length-1; i>=0; i--) {
79                this.source.length++;
80                this.source[this.source.length-1] = arg[i];
81            }
82        }
83
84        this.ignoreCase = 0;
85        if (arguments[1] == "ignorecase") {
86            this.ignoreCase = 1;
87        }
88    },
89
90    // method: given prefix, returns best suffix, or null if no answer
91    bestFinish: function (pre) {
92        if (! pre || pre.length == 0)
93            return null;
94
95        if (! this.source)
96            return null;
97
98        var i;
99        for (i=this.source.length-1; i>=0; i--) {
100            var item = this.source[i];
101
102            var itemToCompare = item;
103            var preToCompare = pre;
104            if (this.ignoreCase) {
105                item += '';
106                pre += '';
107                itemToCompare = item.toLowerCase();
108                preToCompare = pre.toLowerCase();
109            }
110
111            if (itemToCompare.substring(0, pre.length) == preToCompare) {
112                var suff = item.substring(pre.length, item.length);
113                return suff;
114            }
115        }
116
117        return null;
118    },
119
120    // method: given a piece of data, learn it, and prioritize it for future completions
121    learn: function (word) {
122        if (!word) return false;
123        if (!this.source) return false;
124        this.source[this.source.length++] = word;
125
126        if (this.onModelChange)
127            this.onModelChange();
128    },
129
130    getItems: function () {
131        if (!this.source) return [];
132
133        // return only unique items to caller
134        var uniq = [];
135        var seen = {};
136        for (i=this.source.length-1; i>=0; i--) {
137            var item = this.source[i];
138            if (! seen[item]) {
139                seen[item] = 1;
140                uniq.length++;
141                uniq[uniq.length - 1] = item;
142            }
143        }
144
145        return uniq;
146    },
147
148    dummy: 1
149});
150
151/* ***************************************************************************
152
153  Class: InputComplete
154
155  About:
156
157  Constructor:
158
159*************************************************************************** */
160
161var InputComplete = new Class( Object, {
162    init: function () {
163        var opts = arguments[0];
164        var ele;
165        var model;
166        var debug;
167
168        if (arguments.length == 1) {
169            ele = opts["target"];
170            model = opts["model"];
171            debug = opts["debug"];
172        } else {
173            ele = arguments[0];
174            model = arguments[1];
175            debug = arguments[2];
176        }
177
178        this.ele   = ele;
179        this.model = model;
180        this.debug = debug;
181
182        // no model?  don't setup object.
183        if (! ele) {
184            this.disabled = true;
185            return;
186        }
187
188        // return false if auto-complete won't work anyway
189        if (! (("selectionStart" in ele) || (document.selection && document.selection.createRange)) ) {
190            this.disabled = true;
191            return false;
192        }
193
194        DOM.addEventListener(ele, "focus",   InputComplete.onFocus.bindEventListener(this));
195        DOM.addEventListener(ele, "keydown", InputComplete.onKeyDown.bindEventListener(this));
196        DOM.addEventListener(ele, "keyup",   InputComplete.onKeyUp.bindEventListener(this));
197        DOM.addEventListener(ele, "blur",    InputComplete.onBlur.bindEventListener(this));
198    },
199
200    dbg: function (msg) {
201        if (this.debug) {
202            this.debug(msg);
203        }
204    },
205
206    // returns the word currently being typed, or null
207    wordInProgress: function () {
208        var sel = this.getSelectedRange();
209        if (!sel) return null;
210
211        var cidx = sel.selectionStart; // current indx
212        var sidx = cidx;  // start of word index
213        while (sidx > 0 && this.ele.value.charAt(sidx) != ',') {
214            sidx--;
215        }
216        var skipStartForward = function (chr) { return (chr == "," || chr == " "); }
217
218        while (skipStartForward(this.ele.value.charAt(sidx))) {
219            sidx++;
220        }
221
222        return this.ele.value.substring(sidx, this.ele.value.length);
223    },
224
225    // appends some selected text after the care
226    addSelectedText: function (chars) {
227        var sel = this.getSelectedRange();
228        this.ele.value = this.ele.value + chars;
229        this.setSelectedRange(sel.selectionStart, this.ele.value.length);
230    },
231
232    moveCaretToEnd: function () {
233        var len = this.ele.value.length;
234        this.setSelectedRange(len, len);
235    },
236
237    getSelectedRange: function () {
238        var ret = {};
239        var ele = this.ele;
240
241        if ("selectionStart" in ele) {
242            ret.selectionStart = ele.selectionStart;
243            ret.selectionEnd   = ele.selectionEnd;
244            return ret;
245        }
246
247        if (document.selection && document.selection.createRange) {
248            var range = document.selection.createRange();
249            ret.selectionStart = InputComplete.IEOffset(range, "StartToStart");
250            ret.selectionEnd   = InputComplete.IEOffset(range, "EndToEnd");
251            return ret;
252        }
253
254        return null;
255    },
256
257    setSelectedRange: function (sidx, eidx) {
258        var ele = this.ele;
259
260        // preferred to setting selectionStart and end
261        if (ele.setSelectionRange) {
262            ele.focus();
263            ele.setSelectionRange(sidx, eidx);
264            return true;
265        }
266
267        // IE
268        if (document.selection && document.selection.createRange) {
269            ele.focus();
270            var sel = document.selection.createRange ();
271            sel.moveStart('character', -ele.value.length);
272            sel.moveStart('character', sidx);
273            sel.moveEnd('character', eidx - sidx);
274            sel.select();
275            return true;
276        }
277
278        // mozilla
279        if ("selectionStart" in ele) {
280            ele.selectionStart = sidx;
281            ele.selectionEnd   = eidx;
282            return true;
283        }
284
285        return false;
286    },
287
288    // returns true if caret is at end of line, or everything to the right
289    // of us is selected
290    caretAtEndOfNotSelected: function (sel) {
291        sel = sel || this.getSelectedRange();
292        var len = this.ele.value.length;
293        return sel.selectionEnd == len;
294    },
295
296    disable: function () {
297        this.disabled = true;
298    },
299
300    dummy: 1
301});
302
303InputComplete.onKeyDown = function (e) {
304    if (this.disabled) return;
305
306    var code = e.keyCode || e.which;
307
308    this.dbg("onKeyDown, code="+code+", shift="+e.shiftKey);
309   
310    // if comma, but not with a shift which would be "<".  (FIXME: what about other keyboards layouts?)
311    //FIXME: may be there is a stable cross-browser way to detect so-called other keyboard layouts - but i don't know anything easier than ... (see onKeyUp changes in tis revision)
312    /*if ((code == 188 || code == 44) && ! e.shiftKey && this.caretAtEndOfNotSelected()) {
313        this.moveCaretToEnd();
314        return Event.stop(e);
315    }*/
316
317    return true;
318};
319
320InputComplete.onKeyUp = function (e) {
321    if (this.disabled) return;
322
323    var val = this.ele.value;
324
325    var code = e.keyCode || e.which;
326    this.dbg("keyUp = " + code);
327   
328   
329    // ignore tab, backspace, left, right, delete, and enter
330    if (code == 9 || code == 8 || code == 37 || code == 39 || code == 46 || code == 13)
331       return false;
332
333    var sel = this.getSelectedRange();
334
335    var ss = sel.selectionStart;
336    var se = sel.selectionEnd;
337
338    this.dbg("keyUp, got ss="+ss +  ", se="+se+", val.length="+val.length);
339
340    // only auto-complete if we're at the end of the line
341    if (se != val.length) return false;
342
343    var chr = String.fromCharCode(code);
344
345    this.dbg("keyUp, got chr="+chr);
346    //if (code == 188 || chr == ",") {
347    if(/,$/.test(val)){ 
348        if (! this.caretAtEndOfNotSelected(sel)) {
349            return false;
350        }
351
352        this.dbg("hit comma! .. value = " + this.ele.value);
353
354        this.ele.value = this.ele.value.replace(/[\s,]+$/, "") + ", ";
355        this.moveCaretToEnd();
356
357        return Event.stop(e);
358    }
359
360
361    var inProg = this.wordInProgress();
362    if (!inProg) return true;
363
364    var rest = this.model.bestFinish(inProg);
365
366    if (rest && rest.length > 0) {
367        this.addSelectedText(rest);
368    }
369};
370
371InputComplete.onBlur = function (e) {
372    if (this.disabled) return;
373
374    var tg = e.target;
375    var list = tg.value;
376
377    var noendjunk = list.replace(/[\s,]+$/, "");
378    if (noendjunk != list) {
379        tg.value = list = noendjunk;
380    }
381
382    var tags = list.split(",");
383    for (var i =0; i<tags.length; i++) {
384        var tag = tags[i].replace(/^\s+/,"").replace(/\s+$/,"");
385        if (tag.length) {
386            this.model.learn(tag);
387        }
388    }
389};
390
391InputComplete.onFocus = function (e) {
392    if (this.disabled) return;
393};
394
395
396InputComplete.IEOffset = function ( range, compareType ) {
397    if (this.disabled) return;
398
399    var range2 = range.duplicate();
400    range2.collapse( true );
401    var parent = range2.parentElement();
402    var length = range2.text.length;
403    range2.move("character", -parent.value.length);
404
405    var delta = max( 1, finiteInt( length * 0.5 ) );
406    range2.collapse( true );
407    var offset = 0;
408    var steps = 0;
409
410    // bail after 10k iterations in case of borkage
411    while( (test = range2.compareEndPoints( compareType, range )) != 0 ) {
412        if( test < 0 ) {
413            range2.move( "character", delta );
414            offset += delta;
415        } else {
416            range2.move( "character", -delta );
417            offset -= delta;
418        }
419        delta = max( 1, finiteInt( delta * 0.5 ) );
420        steps++;
421        if( steps > 1000 )
422            throw "unable to find textrange endpoint in " + steps + " steps";
423    }
424
425    return offset;
426};
Note: See TracBrowser for help on using the browser.