Changeset View
Changeset View
Standalone View
Standalone View
externals/javelin/src/lib/control/typeahead/source/TypeaheadSource.js
- This file was added.
| /** | |||||
| * @requires javelin-install | |||||
| * javelin-util | |||||
| * javelin-dom | |||||
| * javelin-typeahead-normalizer | |||||
| * @provides javelin-typeahead-source | |||||
| * @javelin | |||||
| */ | |||||
| /** | |||||
| * @group control | |||||
| */ | |||||
| JX.install('TypeaheadSource', { | |||||
| construct : function() { | |||||
| this._raw = {}; | |||||
| this._lookup = {}; | |||||
| this.setNormalizer(JX.TypeaheadNormalizer.normalize); | |||||
| this._excludeIDs = {}; | |||||
| }, | |||||
| events : ['waiting', 'resultsready', 'complete'], | |||||
| properties : { | |||||
| /** | |||||
| * Allows you to specify a function which will be used to normalize strings. | |||||
| * Strings are normalized before being tokenized, and before being sent to | |||||
| * the server. The purpose of normalization is to strip out irrelevant data, | |||||
| * like uppercase/lowercase, extra spaces, or punctuation. By default, | |||||
| * the @{JX.TypeaheadNormalizer} is used to normalize strings, but you may | |||||
| * want to provide a different normalizer, particiularly if there are | |||||
| * special characters with semantic meaning in your object names. | |||||
| * | |||||
| * @param function | |||||
| */ | |||||
| normalizer : null, | |||||
| /** | |||||
| * If a typeahead query should be processed before being normalized and | |||||
| * tokenized, specify a queryExtractor. | |||||
| * | |||||
| * @param function | |||||
| */ | |||||
| queryExtractor : null, | |||||
| /** | |||||
| * Transformers convert data from a wire format to a runtime format. The | |||||
| * transformation mechanism allows you to choose an efficient wire format | |||||
| * and then expand it on the client side, rather than duplicating data | |||||
| * over the wire. The transformation is applied to objects passed to | |||||
| * addResult(). It should accept whatever sort of object you ship over the | |||||
| * wire, and produce a dictionary with these keys: | |||||
| * | |||||
| * - **id**: a unique id for each object. | |||||
| * - **name**: the string used for matching against user input. | |||||
| * - **uri**: the URI corresponding with the object (must be present | |||||
| * but need not be meaningful) | |||||
| * | |||||
| * You can also give: | |||||
| * - **display**: the text or nodes to show in the DOM. Usually just the | |||||
| * same as ##name##. | |||||
| * - **tokenizable**: if you want to tokenize something other than the | |||||
| * ##name##, for the typeahead to complete on, specify it here. A | |||||
| * selected entry from the typeahead will still insert the ##name## | |||||
| * into the input, but the ##tokenizable## field lets you complete on | |||||
| * non-name things. | |||||
| * | |||||
| * The default transformer expects a three element list with elements | |||||
| * [name, uri, id]. It assigns the first element to both ##name## and | |||||
| * ##display##. | |||||
| * | |||||
| * @param function | |||||
| */ | |||||
| transformer : null, | |||||
| /** | |||||
| * Configures the maximum number of suggestions shown in the typeahead | |||||
| * dropdown. | |||||
| * | |||||
| * @param int | |||||
| */ | |||||
| maximumResultCount : 5, | |||||
| /** | |||||
| * Optional function which is used to sort results. Inputs are the input | |||||
| * string, the list of matches, and a default comparator. The function | |||||
| * should sort the list for display. This is the minimum useful | |||||
| * implementation: | |||||
| * | |||||
| * function(value, list, comparator) { | |||||
| * list.sort(comparator); | |||||
| * } | |||||
| * | |||||
| * Alternatively, you may pursue more creative implementations. | |||||
| * | |||||
| * The `value` is a raw string; you can bind the datasource into the | |||||
| * function and use normalize() or tokenize() to parse it. | |||||
| * | |||||
| * The `list` is a list of objects returned from the transformer function, | |||||
| * see the `transformer` property. These are the objects in the list which | |||||
| * match the value. | |||||
| * | |||||
| * The `comparator` is a sort callback which implements sensible default | |||||
| * sorting rules (e.g., alphabetic order), which you can use as a fallback | |||||
| * if you just want to tweak the results (e.g., put some items at the top). | |||||
| * | |||||
| * The function is called after the user types some text, immediately before | |||||
| * the possible completion results are displayed to the user. | |||||
| * | |||||
| * @param function | |||||
| */ | |||||
| sortHandler : null | |||||
| }, | |||||
| members : { | |||||
| _raw : null, | |||||
| _lookup : null, | |||||
| _excludeIDs : null, | |||||
| _changeListener : null, | |||||
| _startListener : null, | |||||
| bindToTypeahead : function(typeahead) { | |||||
| this._changeListener = typeahead.listen( | |||||
| 'change', | |||||
| JX.bind(this, this.didChange) | |||||
| ); | |||||
| this._startListener = typeahead.listen( | |||||
| 'start', | |||||
| JX.bind(this, this.didStart) | |||||
| ); | |||||
| }, | |||||
| unbindFromTypeahead : function() { | |||||
| this._changeListener.remove(); | |||||
| this._startListener.remove(); | |||||
| }, | |||||
| didChange : function(value) { | |||||
| return; | |||||
| }, | |||||
| didStart : function() { | |||||
| return; | |||||
| }, | |||||
| clearCache : function() { | |||||
| this._raw = {}; | |||||
| this._lookup = {}; | |||||
| }, | |||||
| addExcludeID : function(id) { | |||||
| if (id) { | |||||
| this._excludeIDs[id] = true; | |||||
| } | |||||
| }, | |||||
| removeExcludeID : function (id) { | |||||
| if (id) { | |||||
| delete this._excludeIDs[id]; | |||||
| } | |||||
| }, | |||||
| addResult : function(obj) { | |||||
| obj = (this.getTransformer() || this._defaultTransformer)(obj); | |||||
| if (obj.id in this._raw) { | |||||
| // We're already aware of this result. This will happen if someone | |||||
| // searches for "zeb" and then for "zebra" with a | |||||
| // TypeaheadRequestSource, for example, or the datasource just doesn't | |||||
| // dedupe things properly. Whatever the case, just ignore it. | |||||
| return; | |||||
| } | |||||
| if (__DEV__) { | |||||
| for (var k in {name : 1, id : 1, display : 1, uri : 1}) { | |||||
| if (!(k in obj)) { | |||||
| throw new Error( | |||||
| "JX.TypeaheadSource.addResult(): " + | |||||
| "result must have properties 'name', 'id', 'uri' and 'display'."); | |||||
| } | |||||
| } | |||||
| } | |||||
| this._raw[obj.id] = obj; | |||||
| var t = this.tokenize(obj.tokenizable || obj.name); | |||||
| for (var jj = 0; jj < t.length; ++jj) { | |||||
| this._lookup[t[jj]] = this._lookup[t[jj]] || []; | |||||
| this._lookup[t[jj]].push(obj.id); | |||||
| } | |||||
| }, | |||||
| waitForResults : function() { | |||||
| this.invoke('waiting'); | |||||
| return this; | |||||
| }, | |||||
| /** | |||||
| * Get the raw state of a result by its ID. A number of other events and | |||||
| * mechanisms give a list of result IDs and limited additional data; if you | |||||
| * need to act on the full result data you can look it up here. | |||||
| * | |||||
| * @param scalar Result ID. | |||||
| * @return dict Corresponding raw result. | |||||
| */ | |||||
| getResult : function(id) { | |||||
| return this._raw[id]; | |||||
| }, | |||||
| matchResults : function(value) { | |||||
| // This table keeps track of the number of tokens each potential match | |||||
| // has actually matched. When we're done, the real matches are those | |||||
| // which have matched every token (so the value is equal to the token | |||||
| // list length). | |||||
| var match_count = {}; | |||||
| // This keeps track of distinct matches. If the user searches for | |||||
| // something like "Chris C" against "Chris Cox", the "C" will match | |||||
| // both fragments. We need to make sure we only count distinct matches. | |||||
| var match_fragments = {}; | |||||
| var matched = {}; | |||||
| var seen = {}; | |||||
| var query_extractor = this.getQueryExtractor(); | |||||
| if (query_extractor) { | |||||
| value = query_extractor(value); | |||||
| } | |||||
| var t = this.tokenize(value); | |||||
| // Sort tokens by longest-first. We match each name fragment with at | |||||
| // most one token. | |||||
| t.sort(function(u, v) { return v.length - u.length; }); | |||||
| for (var ii = 0; ii < t.length; ++ii) { | |||||
| // Do something reasonable if the user types the same token twice; this | |||||
| // is sort of stupid so maybe kill it? | |||||
| if (t[ii] in seen) { | |||||
| t.splice(ii--, 1); | |||||
| continue; | |||||
| } | |||||
| seen[t[ii]] = true; | |||||
| var fragment = t[ii]; | |||||
| for (var name_fragment in this._lookup) { | |||||
| if (name_fragment.substr(0, fragment.length) === fragment) { | |||||
| if (!(name_fragment in matched)) { | |||||
| matched[name_fragment] = true; | |||||
| } else { | |||||
| continue; | |||||
| } | |||||
| var l = this._lookup[name_fragment]; | |||||
| for (var jj = 0; jj < l.length; ++jj) { | |||||
| var match_id = l[jj]; | |||||
| if (!match_fragments[match_id]) { | |||||
| match_fragments[match_id] = {}; | |||||
| } | |||||
| if (!(fragment in match_fragments[match_id])) { | |||||
| match_fragments[match_id][fragment] = true; | |||||
| match_count[match_id] = (match_count[match_id] || 0) + 1; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| var hits = []; | |||||
| for (var k in match_count) { | |||||
| if (match_count[k] == t.length && !this._excludeIDs[k]) { | |||||
| hits.push(k); | |||||
| } | |||||
| } | |||||
| this.sortHits(value, hits); | |||||
| var nodes = this.renderNodes(value, hits); | |||||
| this.invoke('resultsready', nodes); | |||||
| this.invoke('complete'); | |||||
| }, | |||||
| sortHits : function(value, hits) { | |||||
| var objs = []; | |||||
| for (var ii = 0; ii < hits.length; ii++) { | |||||
| objs.push(this._raw[hits[ii]]); | |||||
| } | |||||
| var default_comparator = function(u, v) { | |||||
| var key_u = u.sort || u.name; | |||||
| var key_v = v.sort || v.name; | |||||
| return key_u.localeCompare(key_v); | |||||
| }; | |||||
| var handler = this.getSortHandler() || function(value, list, cmp) { | |||||
| list.sort(cmp); | |||||
| }; | |||||
| handler(value, objs, default_comparator); | |||||
| hits.splice(0, hits.length); | |||||
| for (var ii = 0; ii < objs.length; ii++) { | |||||
| hits.push(objs[ii].id); | |||||
| } | |||||
| }, | |||||
| renderNodes : function(value, hits) { | |||||
| var n = Math.min(this.getMaximumResultCount(), hits.length); | |||||
| var nodes = []; | |||||
| for (var kk = 0; kk < n; kk++) { | |||||
| nodes.push(this.createNode(this._raw[hits[kk]])); | |||||
| } | |||||
| return nodes; | |||||
| }, | |||||
| createNode : function(data) { | |||||
| return JX.$N( | |||||
| 'a', | |||||
| { | |||||
| sigil: 'typeahead-result', | |||||
| href: data.uri, | |||||
| name: data.name, | |||||
| rel: data.id, | |||||
| className: 'jx-result' | |||||
| }, | |||||
| data.display | |||||
| ); | |||||
| }, | |||||
| normalize : function(str) { | |||||
| return this.getNormalizer()(str); | |||||
| }, | |||||
| tokenize : function(str) { | |||||
| str = this.normalize(str); | |||||
| if (!str.length) { | |||||
| return []; | |||||
| } | |||||
| return str.split(/\s/g); | |||||
| }, | |||||
| _defaultTransformer : function(object) { | |||||
| return { | |||||
| name : object[0], | |||||
| display : object[0], | |||||
| uri : object[1], | |||||
| id : object[2] | |||||
| }; | |||||
| } | |||||
| } | |||||
| }); | |||||