/* * jQuery ContentEditable AC Autocompletion Plugin * * A signifigant fork of the Original AutoComplete by Jörn Zaeffererrequest * * Copyright (c) 2009 Jörn Zaeffererrequest and Aaron Raddon * * Dual licensed under the MIT and GPL licenses: * http://www.opensource.org/licenses/mit-license.php * http://www.gnu.org/licenses/gpl.html * * Revision: $Id: jquery.autocomplete.js 15 2009-08-22 10:30:27Z joern.zaefferer $ */ (function($) { if (window['log'] == undefined){ window['log'] = { toggle: function() {}, move: function() {}, resize: function() {}, clear: function() {}, debug: function() {}, info: function() {}, warn: function() {}, error: function() {}, profile: function() {} }; } $.fn.extend({ autocomplete: function(urlOrData, options) { var isUrl = typeof urlOrData == "string"; options = $.extend({ formatEditableResult: function(row) { return '@' + row[options.jsonterm] + ' ';}, formatResult: function(row) { return row[options.jsonterm];}, formatItem: function(row) { return row[options.jsonterm]; } }, $.Autocompleter.defaults, { url: isUrl ? urlOrData : null, data: isUrl ? null : urlOrData, delay: isUrl ? $.Autocompleter.defaults.delay : 10, max: options && !options.scroll ? 10 : 150 }, options); // if highlight is set to false, replace it with a do-nothing function options.highlight = options.highlight || function(value) { return value; }; // if the formatMatch option is not specified, then use formatItem for backwards compatibility options.formatMatch = options.formatMatch || options.formatItem; return this.each(function() { new $.Autocompleter(this, options); }); }, result: function(handler) { return this.bind("result", handler); }, search: function(handler) { return this.trigger("search", [handler]); }, flushCache: function() { return this.trigger("flushCache"); }, setOptions: function(options){ return this.trigger("setOptions", [options]); }, unautocomplete: function() { return this.trigger("unautocomplete"); }, notfound: function() { return this.trigger("ace_notfound"); } }); $.Autocompleter = function(input, options) { var KEY = { UP: 38, DOWN: 40, DEL: 46, TAB: 9, RETURN: 13, SHIFT: 16, ESC: 27, COMMA: 188, PAGEUP: 33, PAGEDOWN: 34, BACKSPACE: 8, SPACE: 32, LEFT: 37, RIGHT: 39, AT: 50, POUND:34, DOLLAR:52, SEMIC:59 }; // Create $ object for input element var $input = $(input).attr("autocomplete", "off").addClass(options.inputClass); var timeout; var previousValue = ""; var cache = $.Autocompleter.Cache(options); var hasFocus = 0; var cursorStart = 0; var editable = null; var editableSelection, editableRange; var lastKeyPressCode; var preKeyPressCode; var prePreKeyPressCode; var hotKey = "@"; var autocActive = options.hotkeymode ? false : true; var config = { mouseDownOnSelect: false }; var select = $.Autocompleter.Select(options, input, selectCurrent, config); var blockSubmit; if (options.hotkeymode) { options.multiple = false; } // prevent form submit in opera when selecting with return key $.browser.opera && $(input.form).bind("submit.autocomplete", function() { if (blockSubmit) { blockSubmit = false; return false; } }); if (input.value == undefined && options.width < 1){ editable = $(input)[0]; options.width = $(input).parent().parent().width(); options.left = $(input).parent().parent().offset().left; } // only opera doesn't trigger keydown multiple times while pressed, others don't work with keypress at all $input.bind(($.browser.opera ? "keypress" : "keydown") + ".autocomplete", function(event) { // a keypress means the input has focus // avoids issue where input had focus before the autocomplete was applied hasFocus = 1; var k=event.keyCode || event.which; // keyCode == 0 in Gecko/FF on keypress //log.debug("keypress: " + k); if (k == KEY.RETURN){ log.debug("in k = " + k + ' options.supressReturn =' + options.supressReturn) } // track history, probably should push/pop an array prePreKeyPressCode = preKeyPressCode; preKeyPressCode = lastKeyPressCode; lastKeyPressCode = k; if (options.hotkeymode && autocActive === false){ if (k == KEY.AT && event.shiftKey && (KEY.SPACE == prePreKeyPressCode || prePreKeyPressCode == undefined)){ autocActive = true; log.debug("AutoComplete now active in Hotkey mode") clearTimeout(timeout); timeout = setTimeout(onChange, options.delay); } return; } switch(k) { case KEY.UP: event.preventDefault(); if ( select.visible() ) { select.prev(); } else { onChange(0, true); } break; case KEY.DOWN: event.preventDefault(); if ( select.visible() ) { select.next(); } else { onChange(0, true); } break; case KEY.PAGEUP: event.preventDefault(); if ( select.visible() ) { select.pageUp(); } else { onChange(0, true); } break; case KEY.PAGEDOWN: event.preventDefault(); if ( select.visible() ) { select.pageDown(); } else { onChange(0, true); } break; // matches also semicolon case options.multiple && $.trim(options.multipleSeparator) == "," && KEY.COMMA: case KEY.TAB: case KEY.RETURN: case KEY.RIGHT: case KEY.SEMIC: if (k == KEY.RETURN) event.preventDefault(); if( selectCurrent() ) { // stop default to prevent a form submit, Opera needs special handling log.debug("in k = " + k + ' options.supressReturn =' + options.supressReturn) if (k == KEY.RETURN && options.supressReturn) event.preventDefault(); blockSubmit = true; hideResultsNow(); if (options.hotkeymode || options.multiple === true){ log.debug("in return false") return false; } } else { log.debug("nada found? trigger notfound?"); $input.trigger("ace_notfound", {}); hideResultsNow(); if (k == KEY.RETURN && options.supressReturn) event.preventDefault(); } if (k == KEY.TAB || k == KEY.RETURN){ log.debug("was tab") } break; case KEY.ESC: select.hide(); break; default: if( autocActive === true ) { clearTimeout(timeout); timeout = setTimeout(onChange, options.delay); } break; } }).focus(function(){ // track whether the field has focus, we shouldn't process any // results if the field no longer has focus log.debug("has focus") hasFocus++; if( autocActive === true && options.hotkeymode) { onChange(0, true); } else if (!options.hotkeymode) { if (!autocActive) autocActive = true; onChange(0, true); } }).blur(function() { hasFocus = 0; if (!config.mouseDownOnSelect) { hideResults(); } }).click(function() { // show select when clicking in a focused field if ( hasFocus++ > 1 && !select.visible() ) { onChange(0, true); } else { log.debug('hasfocus = ' + hasFocus); log.debug('hasfocus = ' + (hasFocus > 1)); log.debug('visible()' + (!select.visible())) log.debug('visible()' + select.visible()) } }).bind('result',function(){ hasFocus = 0; hideResults(); }).bind("search", function() { // TODO why not just specifying both arguments? var fn = (arguments.length > 1) ? arguments[1] : null; function findValueCallback(q, data) { var result; if( data && data.length ) { for (var i=0; i < data.length; i++) { if( data[i].result.toLowerCase() == q.toLowerCase() ) { result = data[i]; break; } } } if( typeof fn == "function" ) { fn(result); } else { $input.trigger("result", result && [result.data, result.value]); } } $.each(trimWords(smartVal()), function(i, value) { request(value, findValueCallback, findValueCallback); }); }).bind("flushCache", function() { cache.flush(); }).bind("setOptions", function() { $.extend(options, arguments[1]); // if we've updated the data, repopulate if ( "data" in arguments[1] ) cache.populate(); }).bind("unautocomplete", function() { select.unbind(); $input.unbind(); $(input.form).unbind(".autocomplete"); }); function selectCurrent() { var selected = select.selected(); if( !selected ) return false; var v = selected.result; previousValue = v; if ( options.multiple ) { var words = trimWords(smartVal()); if ( words.length > 1 ) { var seperator = options.multipleSeparator.length; var cursorAt = $(input).selection().start; var wordAt, progress = 0; $.each(words, function(i, word) { progress += word.length; if (cursorAt <= progress) { wordAt = i; return false; } progress += seperator; }); words[wordAt] = v; // TODO this should set the cursor to the right position, but it gets overriden somewhere //$.Autocompleter.Selection(input, progress + seperator, progress + seperator); v = words.join( options.multipleSeparator ); } v += options.multipleSeparator; } else if ( options.hotkeymode) { autocActive = false; var cur = smartVal(); cur = cur.substring(0,cursorStart -1); log.info("found Data! " + selected.data[0] + ' ' + selected.data[1]) v = cur + options.formatResult(selected.data); if (input.value == undefined) { v = v + '—'; } //storeContentEditableCursor(); } smartVal(v); hideResultsNow(); $input.trigger("result", [selected.data, selected.value]); if ( options.hotkeymode && input.value == undefined) editableReturnCursor(); return true; } function setupContentEditable(){ if(editableSelection.getRangeAt !== undefined) { //ok // Get range (Safari 2) } else if( document.createRange && editableSelection.anchorNode && editableSelection.anchorOffset && editableSelection.focusNode && editableSelection.focusOffset ) { var temp = ''; } } function storeContentEditableCursor() { // editable is the contentEditable div // Don't capture selection outside editable region var isOrContainsAnchor = false, isOrContainsFocus = false, sel = window.getSelection(), parentAnchor = sel.anchorNode, parentFocus = sel.focusNode; while(parentAnchor && parentAnchor != document.documentElement) { if(parentAnchor == editable) { isOrContainsAnchor = true; } parentAnchor = parentAnchor.parentNode; } while(parentFocus && parentFocus != document.documentElement) { if(parentFocus == editable) { isOrContainsFocus = true; } parentFocus = parentFocus.parentNode; } if(!isOrContainsAnchor || !isOrContainsFocus) { return; } editableSelection = window.getSelection(); //editableSelection, editableRange; // Get range (standards) if(editableSelection.getRangeAt !== undefined) { editableRange = editableSelection.getRangeAt(0); //log.debug("in contenteditable keyup") // Get range (Safari 2) } else if( document.createRange && editableSelection.anchorNode && editableSelection.anchorOffset && editableSelection.focusNode && editableSelection.focusOffset ) { editableRange = document.createRange(); editableRange.setStart(selection.anchorNode, editableSelection.anchorOffset); editableRange.setEnd(selection.focusNode, editableSelection.focusOffset); } else { // Failure here, not handled by the rest of the script. // Probably IE or some older browser // TODO: gracefully degrate to textarea? } var cursorStartSpan = document.createElement('span'), collapsed = !!editableRange.collapsed; cursorStartSpan.id = 'cursorStart'; cursorStartSpan.appendChild(document.createTextNode('—')); // Insert beginning cursor marker editableRange.insertNode(cursorStartSpan); // Insert end cursor marker if any text is selected if(!collapsed) { var cursorEnd = document.createElement('span'); cursorEnd.id = 'cursorEnd'; editableRange.collapse(); editableRange.insertNode(cursorEnd); } } function editableReturnCursor(){ // Slight delay will avoid the initial selection // (at start or of contents depending on browser) being mistaken setTimeout(function() { var cursorStartSpan = document.getElementById('cursorStart'), cursorEnd = document.getElementById('cursorEnd'); if (window.getSelection) { // Firefox, Safari, Opera editableSelection = window.getSelection(); var range = document.createRange(); range.selectNode(cursorStartSpan); // Select range editableSelection.removeAllRanges(); editableSelection.addRange(range); // Delete cursor marker document.execCommand('delete', false, null); } else { if (document.body.createTextRange) { // Internet Explorer var rangeToSelect = document.body.createTextRange(); rangeToSelect.moveToElementText(cursorStartSpan); rangeToSelect.select(); document.selection.clear(); } } // Register selection again //captureSelection(); }, 10); } function onChange(crap, skipPrevCheck) { if( lastKeyPressCode == KEY.DEL ) { select.hide(); return; } var currentValue = smartVal(); if ( !skipPrevCheck && currentValue == previousValue ){ return; } currentValue = findSearchTerm(currentValue); previousValue = currentValue; log.debug("onChange curVal = " + currentValue) if ( currentValue.length >= options.minChars) { $input.addClass(options.loadingClass); if (!options.matchCase) currentValue = currentValue.toLowerCase(); request(currentValue, receiveData, hideResultsNow); } else { log.debug("in else") stopLoading(); if (options.startmsg != null) { select.emptyList(); select.display({}, null); select.show(); } else { select.hide(); } } }; function trimWords(value) { if (!value) return [""]; if (!options.multiple && !options.hotkeymode) { return [$.trim(value)]; } else if (options.multiple) { return $.map(value.split(options.multipleSeparator), function(word) { return $.trim(value).length ? $.trim(word) : null; }); } else if (options.hotkeymode) { log.error("should not get here, remove this section, not user") return fake.raise.error; } } // find word currently being searched for, anything after // previous results, or non query text function findSearchTerm(value) { if ( !options.multiple && !options.hotkeymode) { return value; } else if (options.multiple) { var words = trimWords(value); if (words.length == 1) return words[0]; var cursorAt = $(input).selection().start; if (cursorAt == value.length) { words = trimWords(value) } else { words = trimWords(value.replace(value.substring(cursorAt), "")); } return words[words.length - 1]; // hotkeymode } else { if (value && value.lastIndexOf('@') >= 0){ cursorStart = value.lastIndexOf('@') + 1; } else { log.error("found no @ in hotkeymode?" + value) cursorStart = value.length + 2; } value = value.substring(cursorStart); log.info("findSearchTerm: cursorStart,val " + cursorStart + ', ' + value) return value; //.trim(); } } // fills in the input box w/the first match (assumed to be the best match) // q: the term entered // sValue: the first matching result function autoFill(q, sValue){ // autofill in the complete box w/the first match as long as the user hasn't entered in more data // if the last user key pressed was backspace, don't autofill if( options.autoFill && (findSearchTerm(smartVal()).toLowerCase() == q.toLowerCase()) && lastKeyPressCode != KEY.BACKSPACE ) { // fill in the value (keep the case the user has typed) smartVal(smartVal() + sValue.substring(findSearchTerm(previousValue).length)); // select the portion of the value not typed by the user (so the next character will erase) $(input).selection(previousValue.length, previousValue.length + sValue.length); } }; // replace .val() with something to handle content-editable fields function smartVal(val) { var field = $input[0]; //if form input field, vs contenteditable div if (field.value != undefined){ if (val != undefined) { return $input.val(val); } else { return $input.val(); } } else { if (val != undefined) { return $input.html(val); } else { val = $input.html(); if (val != undefined && val.length != undefined && val.length > 0) { val = $.trim(val); } else { val = '' } var endval = '', li = 0; // replace
or   BUT, only in last bit (10) or past hotkey // contenteditable appends
to end a lot, or the last space as   if (val.lastIndexOf('@') > 0 || val.length > 10){ li = val.lastIndexOf('@') > 0 ? val.lastIndexOf('@') + 1 : val.length - 10; endval = val.substring(li); val = val.substring(0,val.length - endval.length); //log.debug('used substring @ ' + val) } else { endval = val; val = ''; } // only clean up end of markup, where they are doing insertion? endval = endval.replace("
",'').replace("
",'').replace(" ",' '); val = val + endval; //log.debug("smartVal endval = " + escape(endval) + ' val= ' + escape(val)) return val; } } } function hideResults() { clearTimeout(timeout); timeout = setTimeout(hideResultsNow, 200); }; function hideResultsNow() { var wasVisible = select.visible(); autocActive = false; select.hide(); clearTimeout(timeout); stopLoading(); if (options.mustMatch) { // call search and run callback $input.search( function (result){ // if no value found, clear the input box if( !result ) { if (options.multiple) { var words = trimWords(smartVal()).slice(0, -1); smartVal( words.join(options.multipleSeparator) + (words.length ? options.multipleSeparator : "") ); } else if (options.hotkeymode) { smartVal( "" ); $input.trigger("result", null); } else { smartVal( "" ); $input.trigger("result", null); } } } ); } }; function receiveData(q, data) { if ( data && data.length && hasFocus ) { stopLoading(); select.display(data, q); autoFill(q, data[0].value); select.show(); } else { hideResultsNow(); } }; function request(term, success, failure) { log.debug("in request term = " + term) if (!options.matchCase) term = term.toLowerCase(); log.debug("in request term2 = " + term) var data = cache.load(term); log.debug("in request term3 = " + data) // recieve the cached data if (data && data.length) { log.debug('found cache, not loading ' + term) success(term, data); return; // if an AJAX url has been supplied, try loading the data now } else if( (typeof options.url == "string") && (options.url.length > 0) ){ var extraParams = { timestamp: +new Date() }; $.each(options.extraParams, function(key, param) { extraParams[key] = typeof param == "function" ? param() : param; }); var found = false; log.debug("calling ajax, term = " + term, ' dataType = ' + options.dataType) $.ajax({ // try to leverage ajaxQueue plugin to abort previous requests mode: "abort", // limit abortion to this input port: "autocomplete" + input.name, dataType: options.dataType, url: options.url, data: $.extend({ q: term, limit: options.max }, extraParams), success: function(data) { if (data.length > 0){ found = true; var parsed = options.parse && options.parse(data) || parse_json(data); cache.add(term, parsed); success(term, parsed); } } }); if (found === true){ log.debug("returning?") return; } } log.debug("after load in request?") select.emptyList(); if (options.noresultsmsg != null) { stopLoading(); select.display({}, term); select.show(); } else { // if we have a failure, we need to empty the list -- this prevents the the [TAB] key from selecting the last successful match failure(term); } }; function parse_json(json){ var parsed = []; log.debug("parsing json, len=" + json.length) for ( var i = 0, ol = json.length; i < ol; i++ ) { parsed[i] = { data: json[i], value: json[i][options.jsonterm], result: options.formatResult && options.formatResult(json[i]) || json[i][options.jsonterm] }; } return parsed; } function stopLoading() { $input.removeClass(options.loadingClass); }; }; $.Autocompleter.defaults = { inputClass: "ac_input", resultsClass: "ac_results", loadingClass: "ac_loading", minChars: 1, live:false, startmsg: 'Start typing to get options...', msgonenter:false, endmsg: null, noresultsmsg: null, delay: 400, matchCase: false, matchSubset: true, matchContains: false, cacheLength: 10, supressReturn: false, max: 100, mustMatch: false, extraParams: {}, jsonterm: 0, // 'name' ?? formatResult: null,//placeholder, function in above options formatItem: null, //placeholder, function in above options dataType: 'json', selectFirst: true, formatMatch: null, autoFill: false, width: 0, left: 0, multiple: false, multipleSeparator: ", ", hotkeymode: false, highlight: function(value, term) { return value.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + term.replace(/([\^\$\(\)\[\]\{\}\*\.\+\?\|\\])/gi, "\\$1") + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "$1"); }, scroll: true, scrollHeight: 180 }; $.Autocompleter.Cache = function(options) { var data = {}; var length = 0; function matchSubset(s, sub) { if (!options.matchCase) s = s.toLowerCase(); var i = s.indexOf(sub); if (options.matchContains == "word"){ i = s.toLowerCase().search("\\b" + sub.toLowerCase()); } if (i == -1) return false; return i == 0 || options.matchContains; }; function add(q, value) { if (length > options.cacheLength){ flush(); } if (!data[q]){ length++; } data[q] = value; } function populate(){ if( !options.data ) return false; // track the matches var stMatchSets = {}, nullData = 0; // no url was specified, we need to adjust the cache length to make sure it fits the local data store if( !options.url ) options.cacheLength = 1; // track all options for minChars = 0 stMatchSets[""] = []; // loop through the array and create a lookup structure for ( var i = 0, ol = options.data.length; i < ol; i++ ) { var rawValue = options.data[i]; // if rawValue is a string, make an array otherwise just reference the array rawValue = (typeof rawValue == "string") ? [rawValue] : rawValue; var value = options.formatMatch(rawValue, i+1, options.data.length); if ( value === false || value === undefined) continue; var firstChar = value.charAt(0).toLowerCase(); // if no lookup array for this character exists, look it up now if( !stMatchSets[firstChar] ) stMatchSets[firstChar] = []; // if the match is a string var row = { value: value, data: rawValue, result: options.formatResult && options.formatResult(rawValue) || value }; // push the current match into the set list stMatchSets[firstChar].push(row); // keep track of minChars zero items if ( nullData++ < options.max ) { stMatchSets[""].push(row); } }; // add the data items to the cache $.each(stMatchSets, function(i, value) { // increase the cache size options.cacheLength++; // add to the cache add(i, value); }); } // populate any existing data setTimeout(populate, 25); function flush(){ data = {}; length = 0; } return { flush: flush, add: add, populate: populate, load: function(q) { if (!options.cacheLength || !length) return null; /* * if dealing w/local data and matchContains than we must make sure * to loop through all the data collections looking for matches */ if( !options.url && options.matchContains ){ // track all matches var csub = []; // loop through all the data grids for matches for( var k in data ){ // don't search through the stMatchSets[""] (minChars: 0) cache // this prevents duplicates if( k.length > 0 ){ var c = data[k]; //log.debug("629 " + c + ' ' + k + ' ' + q) $.each(c, function(i, x) { // if we've got a match, add it to the array if (matchSubset(x.value, q)) { csub.push(x); } }); } } return csub; } else // if the exact item exists, use it if (data[q]){ return data[q]; } else if (options.matchSubset) { for (var i = q.length - 1; i >= options.minChars; i--) { var c = data[q.substr(0, i)]; if (c) { var csub = []; $.each(c, function(i, x) { if (matchSubset(x.value, q)) { csub[csub.length] = x; } }); return csub; } } } return null; } }; }; $.Autocompleter.Select = function (options, input, select, config) { var CLASSES = { ACTIVE: "ac_over" }; var listItems, active = -1, data, term = "", needsInit = true, element, list; // Create results function init() { if (!needsInit) return; element = $("
") .hide() .addClass(options.resultsClass) .css("position", "absolute") .appendTo(document.body); list = $("