1 /* 2 * ***** BEGIN LICENSE BLOCK ***** 3 * Zimbra Collaboration Suite Web Client 4 * Copyright (C) 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016 Synacor, Inc. 5 * 6 * The contents of this file are subject to the Common Public Attribution License Version 1.0 (the "License"); 7 * you may not use this file except in compliance with the License. 8 * You may obtain a copy of the License at: https://www.zimbra.com/license 9 * The License is based on the Mozilla Public License Version 1.1 but Sections 14 and 15 10 * have been added to cover use of software over a computer network and provide for limited attribution 11 * for the Original Developer. In addition, Exhibit A has been modified to be consistent with Exhibit B. 12 * 13 * Software distributed under the License is distributed on an "AS IS" basis, 14 * WITHOUT WARRANTY OF ANY KIND, either express or implied. 15 * See the License for the specific language governing rights and limitations under the License. 16 * The Original Code is Zimbra Open Source Web Client. 17 * The Initial Developer of the Original Code is Zimbra, Inc. All rights to the Original Code were 18 * transferred by Zimbra, Inc. to Synacor, Inc. on September 14, 2015. 19 * 20 * All portions of the code are Copyright (C) 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016 Synacor, Inc. All Rights Reserved. 21 * ***** END LICENSE BLOCK ***** 22 */ 23 24 /** 25 * @overview 26 * 27 */ 28 29 /** 30 * Creates a new autocomplete list. The list isn't populated or displayed until some 31 * autocompletion happens. Takes a data class and loader, so that when data is needed (it's 32 * loaded lazily), the loader can be called on the data class. 33 * @class 34 * This class implements autocomplete functionality. It has two main parts: matching data based 35 * on keystroke events, and displaying/managing the list of matches. This class is theoretically 36 * neutral concerning the data that gets matched (as long as its class has an <code>autocompleteMatch()</code> 37 * method), and the field that it's being called from. 38 * 39 * The data class's <code>autocompleteMatch()</code> method should returns a list of matches, where each match is 40 * an object with the following properties: 41 * <table border="1" width="50%"> 42 * <tr><td width="15%">data</td><td>the object being matched</td></tr> 43 * <tr><td>text</td><td>the text to display for this object in the list</td></tr> 44 * <tr><td>[key1]</td><td>a string that may be used to replace the typed text</td></tr> 45 * <tr><td>[keyN]</td><td>a string that may be used to replace the typed text</td></tr> 46 * </table> 47 * 48 * The calling client also specifies the key in the match result for the string that will be used 49 * to replace the typed text (also called the "completion string"). For example, the completion 50 * string for matching contacts could be a full address, or just the email. 51 * 52 * The client may provide additional key event handlers in the form of callbacks. If the callback 53 * explicitly returns true or false, that's what the event handler will return. 54 * 55 * A single autocomplete list view may handle several related input fields. With the "quick complete" feature, there 56 * may be multiple outstanding autocomplete requests to the server. Each request is managed through a context which 57 * has all the information needed to make the request and handle its results. 58 * 59 * 60 * 61 * Using Autocomplete 62 * 63 * Autocomplete kicks in after there is a pause in the typing (that pause has to be at least 300ms by default). Let's say that 64 * you are entering addresses into the To: field while composing an email. You type a few characters and then pause: 65 * 66 * dav 67 * 68 * ZCS will ask the user for people whose name or email address matches "dav", and display the matches in a list that pops up. 69 * The matches will be sorted with the people you email the most at the top. When you select a match, that person's address 70 * will replace the search string ("dav") in the To: field. Typically the address will be in a bubble. 71 * 72 * Davey Jones x 73 * 74 * Quick Complete 75 * 76 * Many times you will know which address you're looking for, and you will type enough characters so that they will appear at 77 * the top of the matches, and then you type semicolon or a return to select them once the list has come up. If you know that 78 * the address you want will appear at the top of the matches based on what you've typed, then there's a way to select it 79 * without waiting for the list to come up: just type a semicolon. For example, let's assume that I email Davey Jones a lot, 80 * and I know that if I type "dav" he will be the first match. I can just type 81 * 82 * dav; 83 * 84 * and continue, whether that's adding more addresses, or moving on to the subject and body (done easily via the Tab key). 85 * Autocompletion will happen in the background, and will automatically replace "dav;" with the first match from the list. If 86 * no matches are found, nothing changes. One way to think of the Quick Complete feature is as the autocomplete version of 87 * Google's "I'm Feeling Lucky", though in this case you have a much better idea of what the results are going to be. You 88 * don't have to wait for the list to appear in order to add the bubble. It gets added for you. 89 * 90 * You can type in multiple Quick Complete strings, and they will all be handled. For example, I could type 91 * 92 * dav;pb;ann;x; 93 * 94 * and see bubbles pop up for Davey Jones, Phil Bates, Ann Miller, and Xavier Gold without any more action on my part. I could 95 * even type "dav;" into the To: field, hit Tab to go to the Cc: field, type "pb;" there, and then Tab to the Subject: field, 96 * and start writing my message. 97 * 98 * One small limitation of Quick Complete is that the bubbles will pop up within a field in the order that the results come 99 * back, which may not match the order of the strings you typed in. You can drag the bubbles to rearrange them if you want. 100 * 101 * Special Keys 102 * 103 * There are a number of keys that have special meanings when you are working with an input field that supports autocomplete. 104 * Most of them apply while the list of matches is showing, and are used to control selection of the match you want: 105 * 106 * Return Adds the selected address 107 * Tab Adds the selected address 108 * ; Adds the selected address 109 * , Adds the selected address (if enabled in Preferences/Address Book/Autocomplete) 110 * DownArrow Selects the next address (hold to repeat) 111 * UpArrow Selects the previous address (hold to repeat) 112 * Esc Hides the list 113 * 114 * A few keys have special meanings while the list is not showing: 115 * 116 * Return If the input contains an email address, turn it into a bubble 117 * Tab Go to the next field 118 * Esc If requests are pending (it will say "Autocompleting"), cancel them. If not, cancel compose. 119 * 120 * 121 * 122 * @author Conrad Damon 123 * 124 * @param {Hash} params a hash of parameters: 125 * @param {String} matchValue the name of field in match result to use for completion 126 * @param {function} dataClass the class that has the data loader 127 * @param {function} dataLoader a method of dataClass that returns data to match against 128 * @param {DwtComposite} parent the control that created this list (defaults to shell) 129 * @param {String} className the CSS class 130 * @param {Array} delims the list of delimiters (which separate tokens such as addresses) 131 * @param {Array} delimCodes the list of delimiter key codes 132 * @param {String} separator the separator (gets added to the end of a match) 133 * @param {AjxCallback} compCallback the callback into client to notify it that completion happened 134 * @param {AjxCallback} selectionCallback the callback into client to notify it that selection from extended DL happened (passed from email.js, and accessed from ZmDLAutocompleteListView.prototype._doUpdate) 135 * @param {AjxCallback} keyDownCallback the additional client ONKEYDOWN handler 136 * @param {AjxCallback} keyPressCallback the additional client ONKEYPRESS handler 137 * @param {AjxCallback} keyUpCallback the additional client ONKEYUP handler 138 * @param {string} contextId ID from parent 139 * @param {Hash} options the additional options for the data class 140 * @param {function} locationCallback used to customize list location (optional) 141 * 142 * @extends DwtComposite 143 */ 144 ZmAutocompleteListView = function(params) { 145 146 if (arguments.length == 0) { 147 return; 148 } 149 150 params.parent = params.parent || appCtxt.getShell(); 151 params.className = params.className || "ZmAutocompleteListView"; 152 params.posStyle = DwtControl.ABSOLUTE_STYLE; 153 params.id = params.contextId ? DwtId.makeId(ZmId.WIDGET_AUTOCOMPLETE, params.contextId) : 154 this._htmlElId || Dwt.getNextId("ZmAutocompleteListView_"); 155 DBG.println("acid", "ID: " + params.id); 156 DwtComposite.call(this, params); 157 158 this._dataClass = this._dataAPI = params.dataClass; 159 this._dataLoader = params.dataLoader; 160 this._dataLoaded = false; 161 this._matchValue = params.matchValue; 162 this._selectionCallback = params.selectionCallback; 163 this._separator = (params.separator != null) ? params.separator : AjxEmailAddress.SEPARATOR; 164 this._options = params.options || {}; 165 this._locationCallback = params.locationCallback; 166 this._autocompleteType = params.autocompleteType; 167 168 this._callbacks = {}; 169 for (var i = 0; i < ZmAutocompleteListView.CALLBACKS.length; i++) { 170 this._setCallbacks(ZmAutocompleteListView.CALLBACKS[i], params); 171 } 172 173 this._isDelim = AjxUtil.arrayAsHash(params.delims || ZmAutocompleteListView.DELIMS); 174 this._isDelimCode = AjxUtil.arrayAsHash(params.delimCodes || ZmAutocompleteListView.DELIM_CODES); 175 if (!params.delims && !params.delimCodes) { 176 this._isDelim[','] = this._isDelimCode[188] = appCtxt.get(ZmSetting.AUTOCOMPLETE_ON_COMMA); 177 var listener = new AjxListener(this, this._settingChangeListener); 178 var aoc = appCtxt.getSettings().getSetting(ZmSetting.AUTOCOMPLETE_ON_COMMA); 179 if (aoc) { 180 aoc.addChangeListener(listener); 181 } 182 } 183 184 // mouse event handling 185 this._setMouseEventHdlrs(); 186 this.addListener(DwtEvent.ONMOUSEDOWN, new AjxListener(this, this._mouseDownListener)); 187 this.addListener(DwtEvent.ONMOUSEOVER, new AjxListener(this, this._mouseOverListener)); 188 this._addSelectionListener(new AjxListener(this, this._listSelectionListener)); 189 this._outsideListener = new AjxListener(null, ZmAutocompleteListView._outsideMouseDownListener); 190 191 // only trigger matching after a sufficient pause 192 this._acInterval = appCtxt.get(ZmSetting.AC_TIMER_INTERVAL); 193 this._acActionId = {}; // per element 194 195 // for managing focus on Tab in Firefox 196 if (AjxEnv.isGeckoBased) { 197 this._focusAction = new AjxTimedAction(null, this._autocompleteFocus); 198 } 199 200 this._origClass = "acRow"; 201 this._selClass = "acRow-selected"; 202 this._showLinkTextClass = "LinkText"; 203 this._hideLinkTextClass = "LinkText-hide"; 204 this._hideSelLinkTextClass = "LinkText-hide-selected"; 205 206 this._contexts = {}; // key is element ID 207 this._inputValue = {}; // key is element ID 208 209 this.setVisible(false); 210 this.setScrollStyle(Dwt.SCROLL); 211 this.reset(); 212 }; 213 214 ZmAutocompleteListView.prototype = new DwtComposite; 215 ZmAutocompleteListView.prototype.constructor = ZmAutocompleteListView; 216 ZmAutocompleteListView.prototype.toString = function() { return "ZmAutocompleteListView"; }; 217 218 ZmAutocompleteListView.CB_ADDR_FOUND = "addrFound"; 219 ZmAutocompleteListView.CB_COMPLETION = "comp"; 220 ZmAutocompleteListView.CB_KEYDOWN = "keyDown"; 221 ZmAutocompleteListView.CB_KEYPRESS = "keyPress"; 222 ZmAutocompleteListView.CB_KEYUP = "keyUp"; 223 ZmAutocompleteListView.CALLBACKS = [ 224 ZmAutocompleteListView.CB_ADDR_FOUND, 225 ZmAutocompleteListView.CB_COMPLETION, 226 ZmAutocompleteListView.CB_KEYDOWN, 227 ZmAutocompleteListView.CB_KEYPRESS, 228 ZmAutocompleteListView.CB_KEYUP 229 ]; 230 231 // map of characters that are completion characters 232 ZmAutocompleteListView.DELIMS = [',', ';', '\n', '\r']; // used when list is not showing 233 ZmAutocompleteListView.DELIM_CODES = [ // used when list is showing 234 DwtKeyEvent.KEY_COMMA, 235 DwtKeyEvent.KEY_SEMICOLON, 236 DwtKeyEvent.KEY_SEMICOLON_1, 237 DwtKeyEvent.KEY_END_OF_TEXT, 238 DwtKeyEvent.KEY_RETURN 239 ]; 240 241 ZmAutocompleteListView.WAIT_ID = "wait"; 242 243 // for list selection with up/down arrows 244 ZmAutocompleteListView.NEXT = -1; 245 ZmAutocompleteListView.PREV = -2; 246 247 // possible states of an autocomplete context 248 ZmAutocompleteListView.STATE_NEW = "NEW"; 249 ZmAutocompleteListView.STATE_REQUEST = "REQUEST"; 250 ZmAutocompleteListView.STATE_RESPONSE = "RESPONSE"; 251 ZmAutocompleteListView.STATE_DONE = "DONE"; 252 253 254 255 256 /** 257 * Handles the on key down event. 258 * 259 * @param {Event} event the event 260 */ 261 ZmAutocompleteListView.onKeyDown = 262 function(ev) { 263 264 ev = DwtUiEvent.getEvent(ev); 265 var key = DwtKeyEvent.getCharCode(ev); 266 var result = true; 267 var element = DwtUiEvent.getTargetWithProp(ev, "_aclvId"); 268 DBG.println("ac", ev.type.toUpperCase() + " in " + (element && element.id) + ": " + key); 269 var aclv = element && DwtControl.ALL_BY_ID[element._aclvId]; 270 if (aclv) { 271 // if the user types a single delimiting character with the list showing, do completion 272 var isDelim = (!ev.shiftKey && (aclv._isDelimCode[key] || (key === DwtKeyEvent.KEY_TAB && aclv.getVisible()))); 273 var visible = aclv.getVisible(); 274 aclv._actionHandled = false; 275 // DBG.println("ac", "key = " + key + ", isDelim: " + isDelim); 276 if (visible && aclv.handleAction(key, isDelim, element)) { 277 aclv._actionHandled = true; 278 result = false; 279 } 280 281 aclv._inputValue[element.id] = element.value; 282 var cbResult = aclv._runCallbacks(ZmAutocompleteListView.CB_KEYDOWN, element && element.id, [ev, aclv, result, element]); 283 // DBG.println("ac", ev.type.toUpperCase() + " cbResult: " + cbResult); 284 result = (cbResult === true || cbResult === false) ? cbResult : result; 285 } 286 if (AjxEnv.isFirefox){ 287 ZmAutocompleteListView.clearTimer(); 288 ZmAutocompleteListView.timer = new AjxTimedAction(this, ZmAutocompleteListView.onKeyUp, [ev]); 289 AjxTimedAction.scheduleAction(ZmAutocompleteListView.timer, 300) 290 } 291 return ZmAutocompleteListView._echoKey(result, ev); 292 }; 293 294 /** 295 * Handles the on key press event. 296 * 297 * @param {Event} event the event 298 */ 299 ZmAutocompleteListView.onKeyPress = 300 function(ev) { 301 ev = DwtUiEvent.getEvent(ev); 302 DwtKeyEvent.geckoCheck(ev); 303 var result = true; 304 var key = DwtKeyEvent.getCharCode(ev); 305 var element = DwtUiEvent.getTargetWithProp(ev, "_aclvId"); 306 DBG.println("ac", ev.type.toUpperCase() + " in " + (element && element.id) + ": " + key); 307 var aclv = element && DwtControl.ALL_BY_ID[element._aclvId]; 308 if (aclv) { 309 if (aclv._actionHandled) { 310 result = false; 311 } 312 var cbResult = aclv._runCallbacks(ZmAutocompleteListView.CB_KEYPRESS, element && element.id, [ev, aclv, result, element]); 313 DBG.println("ac", ev.type.toUpperCase() + " cbResult: " + cbResult); 314 result = (cbResult === true || cbResult === false) ? cbResult : true; 315 } 316 317 return ZmAutocompleteListView._echoKey(result, ev); 318 }; 319 320 /** 321 * Handles the on key up event. 322 * 323 * @param {Event} event the event 324 */ 325 ZmAutocompleteListView.onKeyUp = 326 function(ev) { 327 ev = DwtUiEvent.getEvent(ev); 328 var result = true; 329 var key = DwtKeyEvent.getCharCode(ev); 330 var element = DwtUiEvent.getTargetWithProp(ev, "_aclvId"); 331 DBG.println("ac", ev.type.toUpperCase() + " in " + (element && element.id) + ": " + key); 332 var aclv = element && DwtControl.ALL_BY_ID[element._aclvId]; 333 if (aclv) { 334 if (aclv._actionHandled) { 335 result = false; 336 } 337 var result = ZmAutocompleteListView._onKeyUp(ev); 338 var cbResult = aclv._runCallbacks(ZmAutocompleteListView.CB_KEYUP, element && element.id, [ev, aclv, result, element]); 339 DBG.println("ac", ev.type.toUpperCase() + " cbResult: " + cbResult); 340 result = (cbResult === true || cbResult === false) ? cbResult : result; 341 } 342 return ZmAutocompleteListView._echoKey(result, ev); 343 }; 344 345 /** 346 * "onkeyup" handler for performing autocompletion. The reason it's an "onkeyup" handler is that it's the only one 347 * that arrives after the input has been updated. 348 * 349 * @param ev the key event 350 * 351 * @private 352 */ 353 ZmAutocompleteListView._onKeyUp = 354 function(ev) { 355 356 var element = DwtUiEvent.getTargetWithProp(ev, "_aclvId"); 357 if (!element) { 358 return ZmAutocompleteListView._echoKey(true, ev); 359 } 360 361 var aclv = DwtControl.ALL_BY_ID[element._aclvId]; 362 var key = DwtKeyEvent.getCharCode(ev); 363 var value = element.value; 364 var elId = element.id; 365 DBG.println("ac", ev.type + " event, key = " + key + ", value = " + value); 366 ev.inputChanged = (value != aclv._inputValue[elId]); 367 368 // reset timer on any address field key activity 369 if (aclv._acActionId[elId] !== -1 && !DwtKeyMap.IS_MODIFIER[key] && key !== DwtKeyEvent.KEY_TAB) { 370 DBG.println("ac", "canceling autocomplete"); 371 AjxTimedAction.cancelAction(aclv._acActionId[elId]); 372 aclv._acActionId[elId] = -1; 373 } 374 375 // ignore modifier keys (including Shift), or a key with a modifier that makes it nonprintable 376 if (DwtKeyMap.IS_MODIFIER[key] || DwtKeyMapMgr.hasModifier(ev)) { 377 return true; 378 } 379 380 // if the input is empty, clear the list (if it's for this input) 381 if (!value && aclv._currentContext && element == aclv._currentContext.element) { 382 aclv.reset(element); 383 return true; 384 } 385 386 // a Return following an address turns it into a bubble 387 if (DwtKeyEvent.IS_RETURN[key] && aclv._complete(element)) { 388 return false; 389 } 390 391 // skip if input value is not changed 392 if (!ev.inputChanged) { 393 return true; 394 } 395 396 ZmAutocompleteListView.clearTimer(); 397 398 // regular input, schedule autocomplete 399 var ev1 = new DwtKeyEvent(); 400 DwtKeyEvent.copy(ev1, ev); 401 ev1.aclv = aclv; 402 ev1.element = element; 403 DBG.println("ac", "scheduling autocomplete for: " + elId); 404 405 var aif = DwtControl.ALL_BY_ID[element._aifId]; 406 if (aif && aif._editMode) { 407 return false; 408 } 409 410 var acAction = new AjxTimedAction(aclv, aclv._autocompleteAction, [ev1]); 411 aclv._acActionId[elId] = AjxTimedAction.scheduleAction(acAction, aclv._acInterval); 412 413 return true; 414 }; 415 416 ZmAutocompleteListView.clearTimer = 417 function(ev){ 418 if (ZmAutocompleteListView.timer){ 419 AjxTimedAction.cancelAction(ZmAutocompleteListView.timer) 420 } 421 }; 422 423 /** 424 * Invokes or prevents the browser's default behavior (which is to echo the typed key). 425 * 426 * @param {Boolean} echo if <code>true</code>, echo the key 427 * @param {Event} ev the UI event 428 * 429 * @private 430 */ 431 ZmAutocompleteListView._echoKey = 432 function(echo, ev) { 433 DwtUiEvent.setBehaviour(ev, !echo, echo); 434 return echo; 435 }; 436 437 /** 438 * Hides list if there is a click elsewhere. 439 * 440 * @private 441 */ 442 ZmAutocompleteListView._outsideMouseDownListener = 443 function(ev, context) { 444 445 var curList = context && context.obj; 446 if (curList) { 447 DBG.println("out", "outside listener, cur " + curList.toString() + ": " + curList._htmlElId); 448 curList.show(false); 449 curList.setWaiting(false); 450 } 451 }; 452 453 /** 454 * Sets the active account. 455 * 456 * @param {ZmAccount} account the account 457 */ 458 ZmAutocompleteListView.prototype.setActiveAccount = 459 function(account) { 460 this._activeAccount = account; 461 }; 462 463 /** 464 * Adds autocompletion to the given field by setting key event handlers. 465 * 466 * @param {Element} element an HTML element 467 * @param {string} addrInputId ID of ZmAddressInputField (for addr bubbles) 468 * 469 * @private 470 */ 471 ZmAutocompleteListView.prototype.handle = 472 function(element, addrInputId) { 473 474 var elId = element.id = element.id || Dwt.getNextId(); 475 DBG.println("ac", "HANDLE " + elId); 476 // TODO: use el id instead of expando 477 element._aclvId = this._htmlElId; 478 if (addrInputId) { 479 element._aifId = addrInputId; 480 } 481 this._contexts[elId] = {}; 482 this._acActionId[elId] = -1; 483 Dwt.setHandler(element, DwtEvent.ONKEYDOWN, ZmAutocompleteListView.onKeyDown); 484 Dwt.setHandler(element, DwtEvent.ONKEYPRESS, ZmAutocompleteListView.onKeyPress); 485 Dwt.setHandler(element, DwtEvent.ONKEYUP, ZmAutocompleteListView.onKeyUp); 486 if (AjxEnv.isFirefox){ 487 // don't override the element input handler directly, as DwtControl uses 488 // that for changing style, etc. 489 var control = DwtControl.findControl(element); 490 491 if (control && control.getInputElement && control.getInputElement() === element) { 492 control.addListener(DwtEvent.ONBLUR, ZmAutocompleteListView.clearTimer); 493 } else { 494 Dwt.setHandler(element, DwtEvent.ONBLUR, ZmAutocompleteListView.clearTimer); 495 } 496 } 497 this.isActive = true; 498 }; 499 500 ZmAutocompleteListView.prototype.unhandle = 501 function(element) { 502 DBG.println("ac", "UNHANDLE " + element.id); 503 Dwt.clearHandler(element, DwtEvent.ONKEYDOWN); 504 Dwt.clearHandler(element, DwtEvent.ONKEYPRESS); 505 Dwt.clearHandler(element, DwtEvent.ONKEYUP); 506 this.isActive = false; 507 }; 508 509 // Kicks off an autocomplete cycle, which scans the content of the given input and then 510 // handles the strings it finds, possible making requests to the data provider. 511 ZmAutocompleteListView.prototype.autocomplete = 512 function(element) { 513 514 if (this._dataLoader && !this._dataLoaded) { 515 this._data = this._dataLoader.call(this._dataClass); 516 this._dataAPI = this._data; 517 this._dataLoaded = true; 518 } 519 520 var results = this._parseInput(element); 521 this._process(results, element); 522 }; 523 524 /** 525 * See if the text in the input is an address. If it is, complete it. 526 * 527 * @param {Element} element 528 * @return {boolean} true if the value in the input was completed 529 */ 530 ZmAutocompleteListView.prototype._complete = 531 function(element) { 532 533 var value = element.value; 534 if (this._dataAPI.isComplete && this._dataAPI.isComplete(value)) { 535 DBG.println("ac", "got a Return or Tab, found an addr: " + value); 536 var result = this._parseInput(element)[0]; 537 var context = { 538 element: element, 539 str: result.str, 540 isAddress: true, 541 isComplete: result.isComplete, 542 key: this._getKey(result) 543 } 544 this._update(context); 545 this.reset(element); 546 return true; 547 } 548 return false; 549 }; 550 551 // Parses the content of the given input by splitting the text at delimiters. Returns a list of 552 // objects with information about each string it found. 553 ZmAutocompleteListView.prototype._parseInput = 554 function(element) { 555 556 DBG.println("ac", "parse input for element: " + element.id); 557 var results = []; 558 var text = element && element.value; 559 if (!text) { 560 return results; 561 } 562 DBG.println("ac", "PARSE: " + text); 563 var str = ""; 564 for (var i = 0; i < text.length; i++) { 565 var c = text.charAt(i); 566 if (c == ' ' && !str) { continue; } // ignore leading space 567 var isDelim = this._isDelim[c]; 568 if (isDelim || c == ' ') { 569 // space counts as delim if bubbles are on and the space follows an address 570 var str1 = (this._dataAPI.isComplete && this._dataAPI.isComplete(str, true)); 571 if (str1) { 572 DBG.println("ac", "parse input found address: " + str); 573 str1 = (str1 === true) ? str : str1; 574 results.push({element:element, str:str1, isComplete:true, isAddress:true}); 575 str = ""; 576 } 577 else if (c == ";") { 578 // semicolon triggers Quick Complete 579 results.push({element:element, str:str, isComplete:true}); 580 str = ""; 581 } 582 else { 583 // space typed, but not after an address so no special meaning 584 str += c; 585 } 586 } 587 else { 588 str += c; 589 } 590 } 591 if (str) { 592 results.push({str:str, isComplete:false}); 593 } 594 595 return results; 596 }; 597 598 /** 599 * Look through the parsed contents of the input and make any needed autocomplete requests. If there is a 600 * delimited email address, go ahead and handle it now. Also, make sure to cancel any requests that no 601 * longer match the contents of the input. This function will run only after a pause in the user's typing 602 * (via a setTimeout call), so existing contexts will be in either the REQUEST state or the DONE state. 603 */ 604 ZmAutocompleteListView.prototype._process = 605 function(results, element) { 606 607 // for convenience, create a hash of current keys for this input 608 var resultsHash = {}; 609 for (var i = 0; i < results.length; i++) { 610 var key = this._getKey(results[i]); 611 resultsHash[key] = true; 612 } 613 614 // cancel any outstanding requests for strings that are no longer in the input 615 var pendingContextHash = {}; 616 var oldContexts = this._contexts[element.id]; 617 if (oldContexts && oldContexts.length) { 618 for (var i = 0; i < oldContexts.length; i++) { 619 var context = oldContexts[i]; 620 var key = context.key; 621 if (key && context.reqId && context.state == ZmAutocompleteListView.STATE_REQUEST && !resultsHash[key]) { 622 DBG.println("ac", "request for '" + context.str + "' no longer current, canceling req " + context.reqId); 623 appCtxt.getAppController().cancelRequest(context.reqId); 624 context.state = ZmAutocompleteListView.STATE_DONE; 625 if (context.str == this._waitingStr) { 626 this.setWaiting(false); 627 } 628 } 629 else if (context.state == ZmAutocompleteListView.STATE_REQUEST) { 630 pendingContextHash[context.key] = context; 631 } 632 } 633 } 634 635 // process the parsed content 636 var newContexts = []; 637 for (var i = 0; i < results.length; i++) { 638 var result = results[i]; 639 var str = result.str; 640 var key = this._getKey(result); 641 var pendingContext = pendingContextHash[key]; 642 // see if we already have a pending request for this result; if so, leave it alone 643 if (pendingContext) { 644 DBG.println("ac", "PROCESS: propagate pending context for '" + str + "'"); 645 newContexts.push(pendingContext); 646 } 647 else { 648 // add a new context 649 DBG.println("ac", "PROCESS: add new context for '" + str + "', isComplete: " + result.isComplete); 650 var context = { 651 element: element, 652 str: str, 653 isComplete: result.isComplete, 654 key: key, 655 isAddress: result.isAddress, 656 state: ZmAutocompleteListView.STATE_NEW 657 } 658 newContexts.push(context); 659 if (result.isAddress) { 660 // handle a completed email address now 661 this._update(context); 662 } 663 else { 664 // go get autocomplete results from the data provider 665 this._autocomplete(context); 666 } 667 } 668 } 669 this._contexts[element.id] = newContexts; 670 }; 671 672 // Returns a key that combines the string with whether it's subject to Quick Complete 673 ZmAutocompleteListView.prototype._getKey = 674 function(context) { 675 return context.str + (context.isComplete ? this._separator : ""); 676 }; 677 678 /** 679 * Resets the visible state of the autocomplete list. The state-related properties are not 680 * per-element because there can only be one visible autocomplete list. 681 */ 682 ZmAutocompleteListView.prototype.reset = 683 function(element) { 684 685 DBG.println("ac", "RESET"); 686 this._matches = null; 687 this._selected = null; 688 689 this._matchHash = {}; 690 this._forgetLink = {}; 691 this._expandLink = {}; 692 693 this.show(false); 694 if (this._memberListView) { 695 this._memberListView.show(false); 696 } 697 this.setWaiting(false); 698 699 if (element) { 700 this._removeDoneRequests(element); 701 } 702 }; 703 704 /** 705 * Checks the given key to see if it's used to control the autocomplete list in some way. 706 * If it does, the action is taken and the key won't be echoed into the input area. 707 * 708 * The following keys are action keys: 709 * 38 40 up/down arrows (list selection) 710 * 37 39 left/right arrows (dl expansion) 711 * 27 escape (hide list) 712 * 713 * The following keys are delimiters (trigger completion when list is up): 714 * 3 13 return 715 * 9 tab 716 * 59 186 semicolon 717 * 188 comma (depends on user pref) 718 * 719 * @param {int} key a numeric key code 720 * @param {boolean} isDelim true if a single delimiter key was typed 721 * @param {Element} element element key event happened in 722 * 723 * @private 724 */ 725 ZmAutocompleteListView.prototype.handleAction = function(key, isDelim, element) { 726 727 DBG.println("ac", "autocomplete handleAction for key " + key + " / " + isDelim); 728 729 if (isDelim) { 730 this._update(); 731 } 732 else if (key === DwtKeyEvent.KEY_ARROW_RIGHT) { 733 // right arrow 734 var dwttext = this._expandText && this._expandText[this._selected]; 735 736 // if the caret is at the end of the input, expand a distribution list, 737 // if possible 738 if(!dwttext || Dwt.getSelectionStart(element) !== element.value.length) { 739 return false; 740 } 741 742 // fake a click 743 dwttext.notifyListeners(DwtEvent.ONMOUSEDOWN); 744 745 } 746 else if (key === DwtKeyEvent.KEY_ARROW_UP || key === DwtKeyEvent.KEY_ARROW_DOWN) { 747 // handle up and down arrow keys 748 if (this.size() < 1) { 749 return; 750 } 751 if (key === DwtKeyEvent.KEY_ARROW_DOWN) { 752 this._setSelected(ZmAutocompleteListView.NEXT); 753 } 754 else if (key === DwtKeyEvent.KEY_ARROW_UP) { 755 this._setSelected(ZmAutocompleteListView.PREV); 756 } 757 } 758 else if (key === DwtKeyEvent.KEY_ESCAPE) { 759 if (this.getVisible()) { 760 this.reset(element); // ESC hides the list 761 } 762 else if (!this._cancelPendingRequests(element)) { 763 return false; 764 } 765 } 766 else if (key === DwtKeyEvent.KEY_TAB) { 767 this._popdown(); 768 return false; 769 } 770 else { 771 return false; 772 } 773 return true; 774 }; 775 776 // Cancels the XHR of any context in the REQUEST state. 777 ZmAutocompleteListView.prototype._cancelPendingRequests = 778 function(element) { 779 780 var foundOne = false; 781 var contexts = this._contexts[element.id]; 782 if (contexts && contexts.length) { 783 for (var i = 0; i < contexts.length; i++) { 784 var context = contexts[i]; 785 if (context.state == ZmAutocompleteListView.STATE_REQUEST) { 786 DBG.println("ac", "user-initiated cancel of request for '" + context.str + "', " + context.reqId); 787 appCtxt.getAppController().cancelRequest(context.reqId); 788 context.state = ZmAutocompleteListView.STATE_DONE; 789 foundOne = true; 790 } 791 } 792 } 793 this.setWaiting(false); 794 795 return foundOne; 796 }; 797 798 // Clean up contexts we are done with 799 ZmAutocompleteListView.prototype._removeDoneRequests = 800 function(element) { 801 802 var contexts = this._contexts[element.id]; 803 var newContexts = []; 804 if (contexts && contexts.length) { 805 for (var i = 0; i < contexts.length; i++) { 806 var context = contexts[i]; 807 if (context.state == ZmAutocompleteListView.STATE_DONE) { 808 newContexts.push(context); 809 } 810 } 811 } 812 this._contexts[element.id] = newContexts; 813 }; 814 815 /** 816 * Sets the waiting status. 817 * 818 * @param {Boolean} on if <code>true</code>, turn waiting "on" 819 * @param {string} str string that pending request is for 820 * 821 */ 822 ZmAutocompleteListView.prototype.setWaiting = 823 function(on, str) { 824 825 if (!on && !this._waitingDiv) { 826 return; 827 } 828 829 var div = this._waitingDiv; 830 if (!div) { 831 div = this._waitingDiv = document.createElement("div"); 832 div.className = "acWaiting"; 833 var html = [], idx = 0; 834 html[idx++] = "<table role='presentation' cellpadding=0 cellspacing=0 border=0>"; 835 html[idx++] = "<tr>"; 836 html[idx++] = "<td><div class='ImgSpinner'></div></td>"; 837 html[idx++] = "<td>" + ZmMsg.autocompleteWaiting + "</td>"; 838 html[idx++] = "</tr>"; 839 html[idx++] = "</table>"; 840 div.innerHTML = html.join(""); 841 Dwt.setPosition(div, Dwt.ABSOLUTE_STYLE); 842 appCtxt.getShell().getHtmlElement().appendChild(div); 843 } 844 845 if (on) { 846 this._popdown(); 847 var loc = this._getDefaultLoc(); 848 Dwt.setLocation(div, loc.x, loc.y); 849 850 this._setLiveRegionText(ZmMsg.autocompleteWaiting); 851 } 852 this._waitingStr = on ? str : ""; 853 854 Dwt.setZIndex(div, on ? Dwt.Z_DIALOG_MENU : Dwt.Z_HIDDEN); 855 Dwt.setVisible(div, on); 856 }; 857 858 // Private methods 859 860 /** 861 * Called as a timed action, after a sufficient pause in typing within an address field. 862 * 863 * @private 864 */ 865 ZmAutocompleteListView.prototype._autocompleteAction = 866 function(ev) { 867 var aclv = ev.aclv; 868 aclv._acActionId[ev.element.id] = -1; // so we don't try to cancel 869 aclv.autocomplete(ev.element); 870 }; 871 872 /** 873 * Displays the current matches in a popup list, selecting the first. 874 * 875 * @param {Boolean} show if <code>true</code>, display the list 876 * @param {String} loc where to display the list 877 * 878 */ 879 ZmAutocompleteListView.prototype.show = 880 function(show, loc) { 881 882 if (show) { 883 this.setWaiting(false); 884 this._popup(loc); 885 } else { 886 this._popdown(); 887 } 888 }; 889 890 // Makes an autocomplete request to the data provider. 891 ZmAutocompleteListView.prototype._autocomplete = 892 function(context) { 893 894 var str = AjxStringUtil.trim(context.str); 895 if (!str || !(this._dataAPI && this._dataAPI.autocompleteMatch)) { 896 return; 897 } 898 DBG.println("ac", "autocomplete: " + context.str); 899 900 this._currentContext = context; // so we can figure out where to pop up the "waiting" indicator 901 var respCallback = this._handleResponseAutocomplete.bind(this, context); 902 context.state = ZmAutocompleteListView.STATE_REQUEST; 903 context.reqId = this._dataAPI.autocompleteMatch(str, respCallback, this, this._options, this._activeAccount, this._autocompleteType); 904 DBG.println("ac", "Request ID for " + context.element.id + " / '" + context.str + "': " + context.reqId); 905 }; 906 907 ZmAutocompleteListView.prototype._handleResponseAutocomplete = 908 function(context, list) { 909 910 context.state = ZmAutocompleteListView.STATE_RESPONSE; 911 912 if (list && list.length) { 913 DBG.println("ac", "matches found for '" + context.str + "': " + list.length); 914 context.list = list; 915 if (context.isComplete) { 916 // doing Quick Complete, go ahead and update with the first match 917 DBG.println("ac", "performing quick completion for: " + context.str); 918 this._update(context, list[0]); 919 } else { 920 // pop up the list of matches 921 this._set(list, context); 922 this._currentContext = context; 923 this.show(true); 924 } 925 } else if (!context.isComplete) { 926 this._popdown(); 927 this._showNoResults(); 928 929 var msg = AjxMessageFormat.format(ZmMsg.autocompleteMatches, 0); 930 this._setLiveRegionText(msg); 931 } 932 }; 933 934 // Returns the field in the match that we show the user. 935 ZmAutocompleteListView.prototype._getCompletionValue = 936 function(match) { 937 var value = ""; 938 if (this._matchValue instanceof Array) { 939 for (var i = 0, len = this._matchValue.length; i < len; i++) { 940 if (match[this._matchValue[i]]) { 941 value = match[this._matchValue[i]]; 942 break; 943 } 944 } 945 } else { 946 value = match[this._matchValue] || ""; 947 } 948 return value; 949 }; 950 951 // Updates the content of the input with the given match and adds a bubble 952 ZmAutocompleteListView.prototype._update = 953 function(context, match) { 954 955 context = context || this._currentContext; 956 if (!context) { 957 return; 958 } 959 match = match || this._matchHash[this._selected]; 960 961 if (match && match.needDerefGroup) { 962 var contact = new ZmContact(match.groupId, {}); 963 var continuationCb = new AjxCallback(this, this._updateContinuation, [context, match]); 964 var derefCallback = new AjxCallback(match, match.setContactGroupMembers, [match.groupId, continuationCb]); 965 contact.load(derefCallback, null, null, true); 966 } 967 else { 968 this._updateContinuation(context, match); 969 } 970 }; 971 972 // continuation of _update 973 ZmAutocompleteListView.prototype._updateContinuation = 974 function(context, match) { 975 976 var newText = ""; 977 var address = context.address = context.address || (context.isAddress && context.str) || (match && this._getCompletionValue(match)); 978 DBG.println("ac", "UPDATE: result for '" + context.str + "' is " + AjxStringUtil.htmlEncode(address)); 979 980 var bubbleAdded = this._addBubble(context, match, context.isComplete); 981 if (!bubbleAdded) { 982 newText = address + this._separator; 983 } 984 985 // figure out what the content of the input should now be 986 var el = context.element; 987 if (el) { 988 // context.add means don't change the content (used by DL selection) 989 if (!context.add) { 990 // Parse the input again so we know what to replace. There is a race condition here, since the user 991 // may have altered the content during the request. In that case, the altered content will not match 992 // and get replaced, which is fine. Reparsing the input seems like a better option than trying to use 993 // regexes. 994 var results = this._parseInput(el); 995 var newValue = ""; 996 for (var i = 0; i < results.length; i++) { 997 var result = results[i]; 998 var key = this._getKey(result); 999 // Compare el.value to key too. Edge case: user types complete email and presses enter 1000 // before new autocomplete request is sent. In this case context.key is only a part of key and el.value. 1001 // Bug 86577 1002 if (context.key === key || el.value === key) { 1003 newValue += newText; 1004 } 1005 else { 1006 newValue += key; 1007 } 1008 } 1009 if (bubbleAdded) { 1010 newValue = AjxStringUtil.trim(newValue); 1011 } 1012 if (el.value !== newValue) { 1013 el.value = newValue; 1014 } 1015 } 1016 1017 if (!context.isComplete) { 1018 // match was selected from visible list, refocus the input and clear the list 1019 el.focus(); 1020 this.reset(el); 1021 } 1022 } 1023 context.state = ZmAutocompleteListView.STATE_DONE; 1024 1025 this._runCallbacks(ZmAutocompleteListView.CB_COMPLETION, el && el.id, [address, el, match]); 1026 }; 1027 1028 // Adds a bubble. If we are adding it via Quick Complete, we don't want the input field to set 1029 // focus since the user may have tabbed into another input field. 1030 ZmAutocompleteListView.prototype._addBubble = 1031 function(context, match, noFocus) { 1032 1033 var el = context.element; 1034 var addrInput = el && el._aifId && DwtControl.ALL_BY_ID[el._aifId]; 1035 if (addrInput) { 1036 var bubbleCount = addrInput.getBubbleCount(); 1037 1038 if (match && match.multipleAddresses) { 1039 // mass complete (add all) from a DL 1040 addrInput.addValue(context.address); 1041 } 1042 else { 1043 var addedClass = this._dataAPI && this._dataAPI.getAddedBubbleClass && this._dataAPI.getAddedBubbleClass(context.str); 1044 var bubbleParams = { 1045 address: context.address, 1046 match: match, 1047 noFocus: noFocus, 1048 addClass: addedClass, 1049 noParse: this._options.noBubbleParse 1050 } 1051 addrInput.addBubble(bubbleParams); 1052 } 1053 1054 var msg = AjxMessageFormat.format(ZmMsg.autocompleteAddressesAdded, 1055 addrInput.getBubbleCount() - bubbleCount); 1056 this._setLiveRegionText(msg); 1057 1058 el = addrInput._input; 1059 // Input field loses focus along the way. Restore it when the stack is finished 1060 if (AjxEnv.isIE) { 1061 AjxTimedAction.scheduleAction(new AjxTimedAction(addrInput, addrInput.focus), 0); 1062 } 1063 return true; 1064 } 1065 else { 1066 return false; 1067 } 1068 }; 1069 1070 // Listeners 1071 1072 // MOUSE_DOWN selects a match and performs an update. Note that we don't wait for 1073 // a corresponding MOUSE_UP event. 1074 ZmAutocompleteListView.prototype._mouseDownListener = 1075 function(ev) { 1076 ev = DwtUiEvent.getEvent(ev); 1077 var row = DwtUiEvent.getTargetWithProp(ev, "id"); 1078 if (!row || !row.id || row.id.indexOf("Row") === -1) { 1079 return; 1080 } 1081 if (ev.button == DwtMouseEvent.LEFT) { 1082 this._setSelected(row.id); 1083 if (this.isListenerRegistered(DwtEvent.SELECTION)) { 1084 var selEv = DwtShell.selectionEvent; 1085 DwtUiEvent.copy(selEv, ev); 1086 selEv.detail = 0; 1087 this.notifyListeners(DwtEvent.SELECTION, selEv); 1088 return true; 1089 } 1090 } 1091 }; 1092 1093 // Mouse over selects a match 1094 ZmAutocompleteListView.prototype._mouseOverListener = 1095 function(ev) { 1096 ev = DwtUiEvent.getEvent(ev); 1097 var row = Dwt.findAncestor(DwtUiEvent.getTarget(ev), "id"); 1098 if (row) { 1099 this._setSelected(row.id); 1100 } 1101 return true; 1102 }; 1103 1104 // Seems like DwtComposite should define this method 1105 ZmAutocompleteListView.prototype._addSelectionListener = 1106 function(listener) { 1107 this._eventMgr.addListener(DwtEvent.SELECTION, listener); 1108 }; 1109 1110 ZmAutocompleteListView.prototype._listSelectionListener = 1111 function(ev) { 1112 this._update(); 1113 }; 1114 1115 // Layout 1116 1117 // Lazily create main table, since we may need it to show "Waiting..." row before 1118 // a call to _set() is made. 1119 ZmAutocompleteListView.prototype._getTable = 1120 function() { 1121 1122 var table = this._tableId && document.getElementById(this._tableId); 1123 if (!table) { 1124 var html = [], idx = 0; 1125 this._tableId = this.getHTMLElId() + '_table'; 1126 html[idx++] = "<table role='presentation' id='" + this._tableId + "' cellpadding=0 cellspacing=0 border=0>"; 1127 html[idx++] = "</table>"; 1128 this.getHtmlElement().innerHTML = html.join(""); 1129 table = document.getElementById(this._tableId); 1130 } 1131 return table; 1132 }; 1133 1134 ZmAutocompleteListView.prototype._setLiveRegionText = 1135 function(text) { 1136 // Lazily create accessibility live region 1137 var id = this.getHTMLElId() + '_liveRegion'; 1138 var liveRegion = Dwt.byId(id); 1139 1140 if (!liveRegion) { 1141 liveRegion = document.createElement('div'); 1142 liveRegion.id = id; 1143 liveRegion.className = 'ScreenReaderOnly'; 1144 liveRegion.setAttribute('role', 'alert'); 1145 liveRegion.setAttribute('aria-label', ZmMsg.autocomplete); 1146 liveRegion.setAttribute('aria-live', 'assertive'); 1147 liveRegion.setAttribute('aria-relevant', 'additions'); 1148 liveRegion.setAttribute('aria-atomic', true); 1149 appCtxt.getShell().getHtmlElement().appendChild(liveRegion); 1150 } 1151 1152 // Set the live region text content 1153 Dwt.removeChildren(liveRegion); 1154 if (text) { 1155 var paragraph = document.createElement('p'); 1156 paragraph.appendChild(document.createTextNode(text)); 1157 liveRegion.appendChild(paragraph); 1158 } 1159 }; 1160 1161 // Creates the list and its member elements based on the matches we have. Each match becomes a 1162 // row. The first match is automatically selected. 1163 ZmAutocompleteListView.prototype._set = 1164 function(list, context) { 1165 1166 this._removeAll(); 1167 var table = this._getTable(); 1168 this._matches = list; 1169 var forgetEnabled = (this._options.supportForget !== false); 1170 var expandEnabled = (this._options.supportExpand !== false); 1171 var len = this._matches.length; 1172 for (var i = 0; i < len; i++) { 1173 var match = this._matches[i]; 1174 if (match && (match.text || match.icon)) { 1175 var rowId = match.id = this._getId("Row", i); 1176 this._matchHash[rowId] = match; 1177 var row = table.insertRow(-1); 1178 row.className = this._origClass; 1179 row.id = rowId; 1180 row.index = i; 1181 var html = [], idx = 0; 1182 var cell = row.insertCell(-1); 1183 cell.className = "AutocompleteMatchIcon"; 1184 if (match.icon) { 1185 cell.innerHTML = (match.icon.indexOf('Dwt') !== -1) ? ["<div class='", match.icon, "'></div>"].join("") : 1186 AjxImg.getImageHtml(match.icon); 1187 } else { 1188 cell.innerHTML = " "; 1189 } 1190 cell = row.insertCell(-1); 1191 cell.innerHTML = match.text || " "; 1192 if (forgetEnabled) { 1193 this._insertLinkCell(this._forgetLink, row, rowId, this._getId("Forget", i), (match.score > 0)); 1194 } 1195 if (expandEnabled) { 1196 this._insertLinkCell(this._expandLink, row, rowId, this._getId("Expand", i), match.canExpand); 1197 } 1198 } 1199 } 1200 if (forgetEnabled) { 1201 this._forgetText = {}; 1202 this._addLinks(this._forgetText, "Forget", ZmMsg.forget, ZmMsg.forgetTooltip, this._handleForgetLink, context); 1203 } 1204 if (expandEnabled) { 1205 this._expandText = {}; 1206 this._addLinks(this._expandText, "Expand", ZmMsg.expand, ZmMsg.expandTooltip, this.expandDL, context); 1207 } 1208 1209 var msg = AjxMessageFormat.format(ZmMsg.autocompleteMatches, len); 1210 this._setLiveRegionText(msg); 1211 1212 AjxTimedAction.scheduleAction(new AjxTimedAction(this, 1213 function() { 1214 this._setSelected(this._getId("Row", 0)); 1215 }), 100); 1216 }; 1217 1218 ZmAutocompleteListView.prototype._showNoResults = 1219 function() { 1220 // do nothing. Overload to show something. 1221 }; 1222 1223 ZmAutocompleteListView.prototype._insertLinkCell = 1224 function(hash, row, rowId, linkId, addLink) { 1225 hash[rowId] = addLink ? linkId : null; 1226 var cell = row.insertCell(-1); 1227 cell.className = "Link"; 1228 cell.innerHTML = addLink ? "<a id='" + linkId + "'></a>" : ""; 1229 }; 1230 1231 ZmAutocompleteListView.prototype._getId = 1232 function(type, num) { 1233 return [this._htmlElId, "ac" + type, num].join("_"); 1234 }; 1235 1236 // Add a DwtText to the link so it can have a tooltip. 1237 ZmAutocompleteListView.prototype._addLinks = 1238 function(textHash, idLabel, label, tooltip, handler, context) { 1239 1240 var len = this._matches.length; 1241 for (var i = 0; i < len; i++) { 1242 var match = this._matches[i]; 1243 var rowId = match.id = this._getId("Row", i); 1244 var linkId = this._getId(idLabel, i); 1245 var link = document.getElementById(linkId); 1246 if (link) { 1247 var textId = this._getId(idLabel + "Text", i); 1248 var text = new DwtText({parent:this, className:this._hideLinkTextClass, id:textId}); 1249 textHash[rowId] = text; 1250 text.isLinkText = true; 1251 text.setText(label); 1252 text.setToolTipContent(tooltip); 1253 var listener = handler.bind(this, {email:match.email, textId:textId, rowId:rowId, element:context.element}); 1254 text.addListener(DwtEvent.ONMOUSEDOWN, listener); 1255 text.reparentHtmlElement(link); 1256 } 1257 } 1258 }; 1259 1260 ZmAutocompleteListView.prototype._showLink = 1261 function(hash, textHash, rowId, show) { 1262 var text = textHash && textHash[rowId]; 1263 if (text) { 1264 text.setClassName(!show ? this._hideLinkTextClass : 1265 hash[rowId] ? this._showLinkTextClass : this._hideSelLinkTextClass); 1266 } 1267 }; 1268 1269 // Displays the list 1270 ZmAutocompleteListView.prototype._popup = 1271 function(loc) { 1272 1273 if (this.getVisible()) { 1274 return; 1275 } 1276 1277 loc = loc || this._getDefaultLoc(); 1278 var x = loc.x; 1279 var y = loc.y; 1280 1281 var windowSize = this.shell.getSize(); 1282 var availHeight = windowSize.y - y; 1283 var fullHeight = this.size() * this._getRowHeight(); 1284 this.setLocation(Dwt.LOC_NOWHERE, Dwt.LOC_NOWHERE); 1285 this.setVisible(true); 1286 var curSize = this.getSize(); 1287 if (availHeight < fullHeight) { 1288 //we are short add text to alert user to keep typing 1289 this._showMoreResultsText(availHeight); 1290 // if we don't fit, resize so we are scrollable 1291 this.setSize(Dwt.DEFAULT, availHeight - (AjxEnv.isIE ? 30 : 10)); 1292 // see if we need to account for width of vertical scrollbar 1293 var div = this.getHtmlElement(); 1294 if (div.clientWidth != div.scrollWidth) { 1295 this.setSize(curSize.x + Dwt.SCROLLBAR_WIDTH, Dwt.DEFAULT); 1296 } 1297 1298 } else if (curSize.y < fullHeight) { 1299 this.setSize(Dwt.CLEAR, fullHeight); 1300 } else { 1301 this.setSize(Dwt.CLEAR, Dwt.CLEAR); // set back to auto-sizing 1302 } 1303 1304 var newX = (x + curSize.x >= windowSize.x) ? windowSize.x - curSize.x : x; 1305 1306 DBG.println("ac", this.toString() + " popup at: " + newX + "," + y); 1307 this.setLocation(newX, y); 1308 this.setVisible(true); 1309 this.setZIndex(Dwt.Z_DIALOG_MENU); 1310 1311 var omem = appCtxt.getOutsideMouseEventMgr(); 1312 var omemParams = { 1313 id: "ZmAutocompleteListView", 1314 obj: this, 1315 outsideListener: this._outsideListener, 1316 noWindowBlur: appCtxt.get(ZmSetting.IS_DEV_SERVER) 1317 } 1318 omem.startListening(omemParams); 1319 }; 1320 1321 // returns a point with a location just below the input field 1322 ZmAutocompleteListView.prototype._getDefaultLoc = 1323 function() { 1324 1325 if (this._locationCallback) { 1326 return this._locationCallback(); 1327 } 1328 1329 var el = this._currentContext && this._currentContext.element; 1330 if (!el) { 1331 return {}; 1332 } 1333 1334 var elLoc = Dwt.getLocation(el); 1335 var elSize = Dwt.getSize(el); 1336 var x = elLoc.x; 1337 var y = elLoc.y + elSize.y + 3; 1338 DwtPoint.tmp.set(x, y); 1339 return DwtPoint.tmp; 1340 }; 1341 1342 // Hides the list 1343 ZmAutocompleteListView.prototype._popdown = 1344 function() { 1345 1346 if (!this.getVisible()) { 1347 return; 1348 } 1349 DBG.println("out", "popdown " + this.toString() + ": " + this._htmlElId); 1350 1351 if (this._memberListView) { 1352 this._memberListView._popdown(); 1353 } 1354 1355 this.setZIndex(Dwt.Z_HIDDEN); 1356 this.setVisible(false); 1357 this._removeAll(); 1358 this._selected = null; 1359 1360 var omem = appCtxt.getOutsideMouseEventMgr(); 1361 omem.stopListening({id:"ZmAutocompleteListView", obj:this}); 1362 }; 1363 1364 /* 1365 Display message to user that more results are available than fit in the current display 1366 @param {int} availHeight available height of display 1367 */ 1368 ZmAutocompleteListView.prototype._showMoreResultsText = 1369 function (availHeight){ 1370 //over load for implementation 1371 }; 1372 1373 /** 1374 * Selects a match by changing its CSS class. 1375 * 1376 * @param {string} id ID of row to select, or NEXT / PREV 1377 */ 1378 ZmAutocompleteListView.prototype._setSelected = 1379 function(id) { 1380 1381 DBG.println("ac", "setting selected id to " + id); 1382 var table = document.getElementById(this._tableId); 1383 var rows = table && table.rows; 1384 if (!(rows && rows.length)) { 1385 return; 1386 } 1387 1388 var len = rows.length; 1389 1390 // handle selection of next/prev via arrow keys 1391 if (id == ZmAutocompleteListView.NEXT || id == ZmAutocompleteListView.PREV) { 1392 id = this._getRowId(rows, id, len); 1393 if (!id) { 1394 return; 1395 } 1396 } 1397 1398 // make sure the ID matches one of our rows 1399 var found = false; 1400 for (var i = 0; i < len; i++) { 1401 if (rows[i].id == id) { 1402 found = true; 1403 break; 1404 } 1405 } 1406 if (!found) { 1407 return; 1408 } 1409 1410 // select one row, deselect the rest 1411 for (var i = 0; i < len; i++) { 1412 var row = rows[i]; 1413 var curStyle = row.className; 1414 if (row.id == id) { 1415 row.className = this._selClass; 1416 } else if (curStyle != this._origClass) { 1417 row.className = this._origClass; 1418 } 1419 } 1420 1421 // links only shown for selected row 1422 this._showLink(this._forgetLink, this._forgetText, this._selected, false); 1423 this._showLink(this._forgetLink, this._forgetText, id, true); 1424 1425 this._showLink(this._expandLink, this._expandText, this._selected, false); 1426 this._showLink(this._expandLink, this._expandText, id, true); 1427 1428 this._selected = id; 1429 1430 var match = this._matchHash[id]; 1431 var msg; 1432 1433 if (!match) { 1434 msg = AjxStringUtil.convertHtml2Text(Dwt.byId(this._selected)); 1435 } else { 1436 var msg = AjxMessageFormat.format(ZmMsg.autocompleteMatchText, [ match.name, match.email ]); 1437 if (match.isGroup) { 1438 msg = AjxMessageFormat.format(ZmMsg.autocompleteGroup, msg); 1439 } 1440 else if (match.isDL) { 1441 msg = AjxMessageFormat.format(ZmMsg.autocompleteDL, msg); 1442 } 1443 } 1444 1445 this._setLiveRegionText(msg); 1446 }; 1447 1448 ZmAutocompleteListView.prototype._getRowId = 1449 function(rows, id, len) { 1450 1451 if (len < 1) { 1452 return; 1453 } 1454 1455 var idx = -1; 1456 for (var i = 0; i < len; i++) { 1457 if (rows[i].id == this._selected) { 1458 idx = i; 1459 break; 1460 } 1461 } 1462 var newIdx = (id == ZmAutocompleteListView.PREV) ? idx - 1 : idx + 1; 1463 if (newIdx == -1) { 1464 newIdx = len - 1; 1465 } 1466 if (newIdx == len) { 1467 newIdx = 0; 1468 } 1469 1470 if (newIdx >= 0 && newIdx < len) { 1471 Dwt.scrollIntoView(rows[newIdx], this.getHtmlElement()); 1472 return rows[newIdx].id; 1473 } 1474 return null; 1475 }; 1476 1477 ZmAutocompleteListView.prototype._getRowHeight = 1478 function() { 1479 if (!this._rowHeight) { 1480 if (!this.getVisible()) { 1481 this.setLocation(Dwt.LOC_NOWHERE, Dwt.LOC_NOWHERE); 1482 this.setVisible(true); 1483 } 1484 var row = this._getTable().rows[0]; 1485 this._rowHeight = row && Dwt.getSize(row).y; 1486 } 1487 return this._rowHeight || 18; 1488 }; 1489 1490 1491 1492 // Miscellaneous 1493 1494 // Clears the internal list of matches 1495 ZmAutocompleteListView.prototype._removeAll = 1496 function() { 1497 this._matches = null; 1498 var table = this._getTable(); 1499 for (var i = table.rows.length - 1; i >= 0; i--) { 1500 var row = table.rows[i]; 1501 if (row != this._waitingRow) { 1502 table.deleteRow(i); 1503 } 1504 } 1505 this._removeLinks(this._forgetText); 1506 this._removeLinks(this._expandText); 1507 }; 1508 1509 ZmAutocompleteListView.prototype._removeLinks = 1510 function(textHash) { 1511 if (!textHash) { 1512 return; 1513 } 1514 for (var id in textHash) { 1515 var textCtrl = textHash[id]; 1516 if (textCtrl) { 1517 textCtrl.dispose(); 1518 } 1519 } 1520 }; 1521 1522 // Returns the number of matches 1523 ZmAutocompleteListView.prototype.size = 1524 function() { 1525 return this._getTable().rows.length; 1526 }; 1527 1528 // Force focus to the input element (handle Tab in Firefox) 1529 ZmAutocompleteListView.prototype._autocompleteFocus = 1530 function(htmlEl) { 1531 htmlEl.focus(); 1532 }; 1533 1534 ZmAutocompleteListView.prototype._getAcListLoc = 1535 function(ev) { 1536 var element = ev.element; 1537 var loc = Dwt.getLocation(element); 1538 var height = Dwt.getSize(element).y; 1539 return (new DwtPoint(loc.x, loc.y + height)); 1540 }; 1541 1542 ZmAutocompleteListView.prototype._settingChangeListener = 1543 function(ev) { 1544 if (ev.type != ZmEvent.S_SETTING) { 1545 return; 1546 } 1547 if (ev.source.id == ZmSetting.AUTOCOMPLETE_ON_COMMA) { 1548 this._isDelim[','] = this._isDelimCode[188] = appCtxt.get(ZmSetting.AUTOCOMPLETE_ON_COMMA); 1549 } 1550 }; 1551 1552 ZmAutocompleteListView.prototype._handleForgetLink = 1553 function(params) { 1554 if (this._dataAPI.forget) { 1555 this._dataAPI.forget(params.email, this._handleResponseForget.bind(this, params.email, params.rowId)); 1556 } 1557 }; 1558 1559 ZmAutocompleteListView.prototype._handleResponseForget = 1560 function(email, rowId) { 1561 var row = document.getElementById(rowId); 1562 if (row) { 1563 row.parentNode.removeChild(row); 1564 var msg = AjxMessageFormat.format(ZmMsg.forgetSummary, [email]); 1565 appCtxt.setStatusMsg(msg); 1566 } 1567 appCtxt.clearAutocompleteCache(ZmAutocomplete.AC_TYPE_CONTACT); 1568 }; 1569 1570 /** 1571 * Displays a second popup list with the members of the given distribution list. 1572 * 1573 * @param {hash} params hash of params: 1574 * @param {string} params.email address of a distribution list 1575 * @param {string} params.textId ID of link text 1576 * @param {string} params.rowId ID or list view row 1577 * @param {DwtMouseEvent} params.ev mouse event 1578 * @param {DwtPoint} params.loc location to popup at; default is right of parent ACLV 1579 * @param {Element} params.element input element 1580 */ 1581 ZmAutocompleteListView.prototype.expandDL = 1582 function(params) { 1583 1584 if (!this._dataAPI.expandDL) { 1585 return; 1586 } 1587 1588 var mlv = this._memberListView; 1589 if (mlv && mlv.getVisible() && params.textId && this._curExpanded == params.textId) { 1590 // User has clicked "Collapse" link 1591 mlv.show(false); 1592 this._curExpanded = null; 1593 this._setExpandText(params.textId, false); 1594 } else { 1595 // User has clicked "Expand" link 1596 if (mlv && mlv.getVisible()) { 1597 // expanding a DL while another one is showing 1598 this._setExpandText(this._curExpanded, false); 1599 mlv.show(false); 1600 } 1601 var contactsApp = appCtxt.getApp(ZmApp.CONTACTS); 1602 var contact = contactsApp.getContactByEmail(params.email); 1603 if (!contact) { 1604 contact = new ZmContact(null); 1605 contact.initFromEmail(params.email); // don't cache, since it's not a real contact (no ID) 1606 } 1607 contact.isDL = true; 1608 if (params.textId && params.rowId) { 1609 this._curExpanded = params.textId; 1610 this._setExpandText(params.textId, true); 1611 } 1612 this._dataAPI.expandDL(contact, 0, this._handleResponseExpandDL.bind(this, contact, params)); 1613 } 1614 1615 }; 1616 1617 ZmAutocompleteListView.prototype._handleResponseExpandDL = 1618 function(contact, params, matches) { 1619 1620 var mlv = this._memberListView; 1621 if (!mlv) { 1622 mlv = this._memberListView = new ZmDLAutocompleteListView({parent:appCtxt.getShell(), parentAclv:this, 1623 selectionCallback: this._selectionCallback, 1624 expandTextId: params.textId}); 1625 } 1626 mlv._dlContact = contact; 1627 mlv._dlBubbleId = params.textId; 1628 mlv._set(matches, contact); 1629 1630 // default position is just to right of parent ac list 1631 var loc = params.loc; 1632 if (this.getVisible()) { 1633 loc = this.getLocation(); 1634 loc.x += this.getSize().x; 1635 } 1636 1637 mlv.show(true, loc); 1638 if (!mlv._rowHeight) { 1639 var table = document.getElementById(mlv._tableId); 1640 if (table) { 1641 mlv._rowHeight = Dwt.getSize(table.rows[0]).y; 1642 } 1643 } 1644 }; 1645 1646 ZmAutocompleteListView.prototype._setExpandText = 1647 function(textId, expanded) { 1648 var textCtrl = DwtControl.fromElementId(textId); 1649 if (textCtrl && textCtrl.setText) { 1650 textCtrl.setText(expanded ? ZmMsg.collapse : ZmMsg.expand); 1651 } 1652 }; 1653 1654 ZmAutocompleteListView.prototype._setCallbacks = 1655 function(type, params) { 1656 1657 var cbKey = type + "Callback"; 1658 var list = this._callbacks[type] = []; 1659 if (params[cbKey]) { 1660 list.push({callback:params[cbKey]}); 1661 } 1662 }; 1663 1664 /** 1665 * Adds a callback of the given type. In an input ID is provided, then the callback 1666 * will only be run if the event happened in that input. 1667 * 1668 * @param {constant} type autocomplete callback type (ZmAutocompleteListView.CB_*) 1669 * @param {AjxCallback|function} callback callback to add 1670 * @param {string} inputId DOM ID of an input element (optional) 1671 */ 1672 ZmAutocompleteListView.prototype.addCallback = 1673 function(type, callback, inputId) { 1674 this._callbacks[type].push({callback:callback, inputId:inputId}); 1675 }; 1676 1677 ZmAutocompleteListView.prototype._runCallbacks = 1678 function(type, inputId, args) { 1679 1680 var result = null; 1681 var list = this._callbacks[type]; 1682 if (list && list.length) { 1683 for (var i = 0; i < list.length; i++) { 1684 var cbObj = list[i]; 1685 if (inputId && cbObj.inputId && (inputId != cbObj.inputId)) { continue; } 1686 var callback = cbObj.callback; 1687 var r; 1688 if (typeof(callback) == "function") { 1689 r = callback.apply(callback, args); 1690 } 1691 else if (callback && callback.isAjxCallback) { 1692 r = AjxCallback.prototype.run.apply(cbObj.callback, args); 1693 } 1694 if (r === true || r === false) { 1695 result = (result == null) ? r : result && r; 1696 } 1697 } 1698 } 1699 return result; 1700 }; 1701