Wed, 31 Dec 2014 06:55:50 +0100
Added tag UPSTREAM_283F7C6 for changeset ca08bd8f51b2
1 // -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*-
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 let Ci = Components.interfaces;
7 let Cc = Components.classes;
9 dump("### FormHelper.js loaded\n");
11 let HTMLTextAreaElement = Ci.nsIDOMHTMLTextAreaElement;
12 let HTMLInputElement = Ci.nsIDOMHTMLInputElement;
13 let HTMLSelectElement = Ci.nsIDOMHTMLSelectElement;
14 let HTMLIFrameElement = Ci.nsIDOMHTMLIFrameElement;
15 let HTMLDocument = Ci.nsIDOMHTMLDocument;
16 let HTMLHtmlElement = Ci.nsIDOMHTMLHtmlElement;
17 let HTMLBodyElement = Ci.nsIDOMHTMLBodyElement;
18 let HTMLLabelElement = Ci.nsIDOMHTMLLabelElement;
19 let HTMLButtonElement = Ci.nsIDOMHTMLButtonElement;
20 let HTMLOptGroupElement = Ci.nsIDOMHTMLOptGroupElement;
21 let HTMLOptionElement = Ci.nsIDOMHTMLOptionElement;
22 let XULMenuListElement = Ci.nsIDOMXULMenuListElement;
24 /**
25 * Responsible of navigation between forms fields and of the opening of the assistant
26 */
27 function FormAssistant() {
28 addMessageListener("FormAssist:Closed", this);
29 addMessageListener("FormAssist:ChoiceSelect", this);
30 addMessageListener("FormAssist:ChoiceChange", this);
31 addMessageListener("FormAssist:AutoComplete", this);
32 addMessageListener("FormAssist:Update", this);
34 /* Listen text events in order to update the autocomplete suggestions as soon
35 * a key is entered on device
36 */
37 addEventListener("text", this, false);
38 addEventListener("focus", this, true);
39 addEventListener("blur", this, true);
40 addEventListener("pageshow", this, false);
41 addEventListener("pagehide", this, false);
42 addEventListener("submit", this, false);
43 }
45 FormAssistant.prototype = {
46 _els: Cc["@mozilla.org/eventlistenerservice;1"].getService(Ci.nsIEventListenerService),
47 _open: false,
48 _focusSync: false,
49 _debugEvents: false,
50 _selectWrapper: null,
51 _currentElement: null,
52 invalidSubmit: false,
54 get focusSync() {
55 return this._focusSync;
56 },
58 set focusSync(aVal) {
59 this._focusSync = aVal;
60 },
62 get currentElement() {
63 return this._currentElement;
64 },
66 set currentElement(aElement) {
67 if (!aElement || !this._isVisibleElement(aElement)) {
68 return null;
69 }
71 this._currentElement = aElement;
72 gFocusManager.setFocus(this._currentElement, Ci.nsIFocusManager.FLAG_NOSCROLL);
74 // To ensure we get the current caret positionning of the focused
75 // element we need to delayed a bit the event
76 this._executeDelayed(function(self) {
77 // Bug 640870
78 // Sometimes the element inner frame get destroyed while the element
79 // receive the focus because the display is turned to 'none' for
80 // example, in this "fun" case just do nothing if the element is hidden
81 if (self._isVisibleElement(gFocusManager.focusedElement)) {
82 self._sendJsonMsgWrapper("FormAssist:Show");
83 }
84 });
85 return this._currentElement;
86 },
88 open: function formHelperOpen(aElement, aEvent) {
89 // If the click is on an option element we want to check if the parent
90 // is a valid target.
91 if (aElement instanceof HTMLOptionElement &&
92 aElement.parentNode instanceof HTMLSelectElement &&
93 !aElement.disabled) {
94 aElement = aElement.parentNode;
95 }
97 // Don't show the formhelper popup for multi-select boxes, except for touch.
98 if (aElement instanceof HTMLSelectElement && aEvent) {
99 if ((aElement.multiple || aElement.size > 1) &&
100 aEvent.mozInputSource != Ci.nsIDOMMouseEvent.MOZ_SOURCE_TOUCH) {
101 return false;
102 }
103 // Don't fire mouse events on selects; see bug 685197.
104 aEvent.preventDefault();
105 aEvent.stopPropagation();
106 }
108 // The form assistant will close if a click happen:
109 // * outside of the scope of the form helper
110 // * hover a button of type=[image|submit]
111 // * hover a disabled element
112 if (!this._isValidElement(aElement)) {
113 let passiveButtons = { button: true, checkbox: true, file: true, radio: true, reset: true };
114 if ((aElement instanceof HTMLInputElement || aElement instanceof HTMLButtonElement) &&
115 passiveButtons[aElement.type] && !aElement.disabled)
116 return false;
117 return this.close();
118 }
120 // Look for a top editable element
121 if (this._isEditable(aElement)) {
122 aElement = this._getTopLevelEditable(aElement);
123 }
125 // We only work with choice lists or elements with autocomplete suggestions
126 if (!this._isSelectElement(aElement) &&
127 !this._isAutocomplete(aElement)) {
128 return this.close();
129 }
131 // Don't re-open when navigating to avoid repopulating list when changing selection.
132 if (this._isAutocomplete(aElement) && this._open && Util.isNavigationKey(aEvent.keyCode)) {
133 return false;
134 }
136 // Enable the assistant
137 this.currentElement = aElement;
138 return this._open = true;
139 },
141 close: function close() {
142 if (this._open) {
143 this._currentElement = null;
144 sendAsyncMessage("FormAssist:Hide", { });
145 this._open = false;
146 }
148 return this._open;
149 },
151 receiveMessage: function receiveMessage(aMessage) {
152 if (this._debugEvents) Util.dumpLn(aMessage.name);
154 let currentElement = this.currentElement;
155 if ((!this._isAutocomplete(currentElement) &&
156 !getWrapperForElement(currentElement)) ||
157 !currentElement) {
158 return;
159 }
161 let json = aMessage.json;
163 switch (aMessage.name) {
164 case "FormAssist:ChoiceSelect": {
165 this._selectWrapper = getWrapperForElement(currentElement);
166 this._selectWrapper.select(json.index, json.selected);
167 break;
168 }
170 case "FormAssist:ChoiceChange": {
171 // ChoiceChange could happened once we have move to another element or
172 // to nothing, so we should keep the used wrapper in mind.
173 this._selectWrapper = getWrapperForElement(currentElement);
174 this._selectWrapper.fireOnChange();
176 // New elements can be shown when a select is updated so we need to
177 // reconstruct the inner elements array and to take care of possible
178 // focus change, this is why we use "self.currentElement" instead of
179 // using directly "currentElement".
180 this._executeDelayed(function(self) {
181 let currentElement = self.currentElement;
182 if (!currentElement)
183 return;
184 self._currentElement = currentElement;
185 });
186 break;
187 }
189 case "FormAssist:AutoComplete": {
190 try {
191 currentElement = currentElement.QueryInterface(Ci.nsIDOMNSEditableElement);
192 let imeEditor = currentElement.editor.QueryInterface(Ci.nsIEditorIMESupport);
193 if (imeEditor.composing)
194 imeEditor.forceCompositionEnd();
195 }
196 catch(e) {}
198 currentElement.value = json.value;
200 let event = currentElement.ownerDocument.createEvent("Events");
201 event.initEvent("DOMAutoComplete", true, true);
202 currentElement.dispatchEvent(event);
203 break;
204 }
206 case "FormAssist:Closed":
207 currentElement.blur();
208 this._open = false;
209 break;
211 case "FormAssist:Update":
212 this._sendJsonMsgWrapper("FormAssist:Show");
213 break;
214 }
215 },
217 handleEvent: function formHelperHandleEvent(aEvent) {
218 if (this._debugEvents) Util.dumpLn(aEvent.type, this.currentElement);
219 // focus changes should be taken into account only if the user has done a
220 // manual operation like manually clicking
221 let shouldIgnoreFocus = (aEvent.type == "focus" && !this._open && !this.focusSync);
222 if ((!this._open && aEvent.type != "focus") || shouldIgnoreFocus) {
223 return;
224 }
226 let currentElement = this.currentElement;
227 switch (aEvent.type) {
228 case "submit":
229 // submit is a final action and the form assistant should be closed
230 this.close();
231 break;
233 case "pagehide":
234 case "pageshow":
235 // When reacting to a page show/hide, if the focus is different this
236 // could mean the web page has dramatically changed because of
237 // an Ajax change based on fragment identifier
238 if (gFocusManager.focusedElement != currentElement)
239 this.close();
240 break;
242 case "focus":
243 let focusedElement =
244 gFocusManager.getFocusedElementForWindow(content, true, {}) ||
245 aEvent.target;
247 // If a body element is editable and the body is the child of an
248 // iframe we can assume this is an advanced HTML editor, so let's
249 // redirect the form helper selection to the iframe element
250 if (focusedElement && this._isEditable(focusedElement)) {
251 let editableElement = this._getTopLevelEditable(focusedElement);
252 if (this._isValidElement(editableElement)) {
253 this._executeDelayed(function(self) {
254 self.open(editableElement);
255 });
256 }
257 return;
258 }
260 // if an element is focused while we're closed but the element can be handle
261 // by the assistant, try to activate it (only during mouseup)
262 if (!currentElement) {
263 if (focusedElement && this._isValidElement(focusedElement)) {
264 this._executeDelayed(function(self) {
265 self.open(focusedElement);
266 });
267 }
268 return;
269 }
271 if (this._currentElement != focusedElement)
272 this.currentElement = focusedElement;
273 break;
275 case "blur":
276 content.setTimeout(function(self) {
277 if (!self._open)
278 return;
280 // If the blurring causes focus be in no other element,
281 // we should close the form assistant.
282 let focusedElement = gFocusManager.getFocusedElementForWindow(content, true, {});
283 if (!focusedElement)
284 self.close();
285 }, 0, this);
286 break;
288 case "text":
289 if (this._isAutocomplete(aEvent.target)) {
290 this._sendJsonMsgWrapper("FormAssist:AutoComplete");
291 }
292 break;
293 }
294 },
296 _executeDelayed: function formHelperExecuteSoon(aCallback) {
297 let self = this;
298 let timer = new Util.Timeout(function() {
299 aCallback(self);
300 });
301 timer.once(0);
302 },
304 _isEditable: function formHelperIsEditable(aElement) {
305 if (!aElement)
306 return false;
307 let canEdit = false;
309 if (aElement.isContentEditable || aElement.designMode == "on") {
310 canEdit = true;
311 } else if (aElement instanceof HTMLIFrameElement &&
312 (aElement.contentDocument.body.isContentEditable ||
313 aElement.contentDocument.designMode == "on")) {
314 canEdit = true;
315 } else {
316 canEdit = aElement.ownerDocument && aElement.ownerDocument.designMode == "on";
317 }
319 return canEdit;
320 },
322 _getTopLevelEditable: function formHelperGetTopLevelEditable(aElement) {
323 if (!(aElement instanceof HTMLIFrameElement)) {
324 let element = aElement;
326 // Retrieve the top element that is editable
327 if (element instanceof HTMLHtmlElement)
328 element = element.ownerDocument.body;
329 else if (element instanceof HTMLDocument)
330 element = element.body;
332 while (element && !this._isEditable(element))
333 element = element.parentNode;
335 // Return the container frame if we are into a nested editable frame
336 if (element && element instanceof HTMLBodyElement && element.ownerDocument.defaultView != content.document.defaultView)
337 return element.ownerDocument.defaultView.frameElement;
338 }
340 return aElement;
341 },
343 _isAutocomplete: function formHelperIsAutocomplete(aElement) {
344 if (aElement instanceof HTMLInputElement) {
345 if (aElement.getAttribute("type") == "password")
346 return false;
348 let autocomplete = aElement.getAttribute("autocomplete");
349 let allowedValues = ["off", "false", "disabled"];
350 if (allowedValues.indexOf(autocomplete) == -1)
351 return true;
352 }
354 return false;
355 },
357 /*
358 * This function is similar to getListSuggestions from
359 * components/satchel/src/nsInputListAutoComplete.js but sadly this one is
360 * used by the autocomplete.xml binding which is not in used in fennec
361 */
362 _getListSuggestions: function formHelperGetListSuggestions(aElement) {
363 if (!(aElement instanceof HTMLInputElement) || !aElement.list)
364 return [];
366 let suggestions = [];
367 let filter = !aElement.hasAttribute("mozNoFilter");
368 let lowerFieldValue = aElement.value.toLowerCase();
370 let options = aElement.list.options;
371 let length = options.length;
372 for (let i = 0; i < length; i++) {
373 let item = options.item(i);
375 let label = item.value;
376 if (item.label)
377 label = item.label;
378 else if (item.text)
379 label = item.text;
381 if (filter && label.toLowerCase().indexOf(lowerFieldValue) == -1)
382 continue;
383 suggestions.push({ label: label, value: item.value });
384 }
386 return suggestions;
387 },
389 _isValidElement: function formHelperIsValidElement(aElement) {
390 if (!aElement.getAttribute)
391 return false;
393 let formExceptions = { button: true, checkbox: true, file: true, image: true, radio: true, reset: true, submit: true };
394 if (aElement instanceof HTMLInputElement && formExceptions[aElement.type])
395 return false;
397 if (aElement instanceof HTMLButtonElement ||
398 (aElement.getAttribute("role") == "button" && aElement.hasAttribute("tabindex")))
399 return false;
401 return this._isNavigableElement(aElement) && this._isVisibleElement(aElement);
402 },
404 _isNavigableElement: function formHelperIsNavigableElement(aElement) {
405 if (aElement.disabled || aElement.getAttribute("tabindex") == "-1")
406 return false;
408 if (aElement.getAttribute("role") == "button" && aElement.hasAttribute("tabindex"))
409 return true;
411 if (this._isSelectElement(aElement) || aElement instanceof HTMLTextAreaElement)
412 return true;
414 if (aElement instanceof HTMLInputElement || aElement instanceof HTMLButtonElement)
415 return !(aElement.type == "hidden");
417 return this._isEditable(aElement);
418 },
420 _isVisibleElement: function formHelperIsVisibleElement(aElement) {
421 if (!aElement || !aElement.ownerDocument) {
422 return false;
423 }
424 let style = aElement.ownerDocument.defaultView.getComputedStyle(aElement, null);
425 if (!style)
426 return false;
428 let isVisible = (style.getPropertyValue("visibility") != "hidden");
429 let isOpaque = (style.getPropertyValue("opacity") != 0);
431 let rect = aElement.getBoundingClientRect();
433 // Since the only way to show a drop-down menu for a select when the form
434 // assistant is enabled is to return true here, a select is allowed to have
435 // an opacity to 0 in order to let web developpers add a custom design on
436 // top of it. This is less important to use the form assistant for the
437 // other types of fields because even if the form assistant won't fired,
438 // the focus will be in and a VKB will popup if needed
439 return isVisible && (isOpaque || this._isSelectElement(aElement)) && (rect.height != 0 || rect.width != 0);
440 },
442 _isSelectElement: function formHelperIsSelectElement(aElement) {
443 return (aElement instanceof HTMLSelectElement || aElement instanceof XULMenuListElement);
444 },
446 /** Caret is used to input text for this element. */
447 _getCaretRect: function _formHelperGetCaretRect() {
448 let element = this.currentElement;
449 let focusedElement = gFocusManager.getFocusedElementForWindow(content, true, {});
450 if (element && (element.mozIsTextField && element.mozIsTextField(false) ||
451 element instanceof HTMLTextAreaElement) && focusedElement == element && this._isVisibleElement(element)) {
452 let utils = Util.getWindowUtils(element.ownerDocument.defaultView);
453 let rect = utils.sendQueryContentEvent(utils.QUERY_CARET_RECT, element.selectionEnd, 0, 0, 0,
454 utils.QUERY_CONTENT_FLAG_USE_XP_LINE_BREAK);
455 if (rect) {
456 let scroll = ContentScroll.getScrollOffset(element.ownerDocument.defaultView);
457 return new Rect(scroll.x + rect.left, scroll.y + rect.top, rect.width, rect.height);
458 }
459 }
461 return new Rect(0, 0, 0, 0);
462 },
464 /** Gets a rect bounding important parts of the element that must be seen when assisting. */
465 _getRect: function _formHelperGetRect(aOptions={}) {
466 const kDistanceMax = 100;
467 let element = this.currentElement;
468 let elRect = getBoundingContentRect(element);
470 if (aOptions.alignToLabel) {
471 let labels = this._getLabels();
472 for (let i=0; i<labels.length; i++) {
473 let labelRect = labels[i].rect;
474 if (labelRect.left < elRect.left) {
475 let isClose = Math.abs(labelRect.left - elRect.left) - labelRect.width < kDistanceMax &&
476 Math.abs(labelRect.top - elRect.top) - labelRect.height < kDistanceMax;
477 if (isClose) {
478 let width = labelRect.width + elRect.width + (elRect.left - labelRect.left - labelRect.width);
479 return new Rect(labelRect.left, labelRect.top, width, elRect.height).expandToIntegers();
480 }
481 }
482 }
483 }
484 return elRect;
485 },
487 _getLabels: function formHelperGetLabels() {
488 let associatedLabels = [];
489 if (!this.currentElement)
490 return associatedLabels;
491 let element = this.currentElement;
492 let labels = element.ownerDocument.getElementsByTagName("label");
493 for (let i=0; i<labels.length; i++) {
494 let label = labels[i];
495 if ((label.control == element || label.getAttribute("for") == element.id) && this._isVisibleElement(label)) {
496 associatedLabels.push({
497 rect: getBoundingContentRect(label),
498 title: label.textContent
499 });
500 }
501 }
503 return associatedLabels;
504 },
506 _sendJsonMsgWrapper: function (aMsg) {
507 let json = this._getJSON();
508 if (json) {
509 sendAsyncMessage(aMsg, json);
510 }
511 },
513 _getJSON: function() {
514 let element = this.currentElement;
515 if (!element) {
516 return null;
517 }
518 let choices = getListForElement(element);
519 let editable = (element instanceof HTMLInputElement && element.mozIsTextField(false)) || this._isEditable(element);
521 let labels = this._getLabels();
522 return {
523 current: {
524 id: element.id,
525 name: element.name,
526 title: labels.length ? labels[0].title : "",
527 value: element.value,
528 maxLength: element.maxLength,
529 type: (element.getAttribute("type") || "").toLowerCase(),
530 choices: choices,
531 isAutocomplete: this._isAutocomplete(element),
532 list: this._getListSuggestions(element),
533 rect: this._getRect(),
534 caretRect: this._getCaretRect(),
535 editable: editable
536 },
537 };
538 },
540 /**
541 * For each radio button group, remove all but the checked button
542 * if there is one, or the first button otherwise.
543 */
544 _filterRadioButtons: function(aNodes) {
545 // First pass: Find the checked or first element in each group.
546 let chosenRadios = {};
547 for (let i=0; i < aNodes.length; i++) {
548 let node = aNodes[i];
549 if (node.type == "radio" && (!chosenRadios.hasOwnProperty(node.name) || node.checked))
550 chosenRadios[node.name] = node;
551 }
553 // Second pass: Exclude all other radio buttons from the list.
554 let result = [];
555 for (let i=0; i < aNodes.length; i++) {
556 let node = aNodes[i];
557 if (node.type == "radio" && chosenRadios[node.name] != node)
558 continue;
559 result.push(node);
560 }
561 return result;
562 }
563 };
564 this.FormAssistant = FormAssistant;
567 /******************************************************************************
568 * The next classes wraps some forms elements such as different type of list to
569 * abstract the difference between html and xul element while manipulating them
570 * - SelectWrapper : <html:select>
571 * - MenulistWrapper : <xul:menulist>
572 *****************************************************************************/
574 function getWrapperForElement(aElement) {
575 let wrapper = null;
576 if (aElement instanceof HTMLSelectElement) {
577 wrapper = new SelectWrapper(aElement);
578 }
579 else if (aElement instanceof XULMenuListElement) {
580 wrapper = new MenulistWrapper(aElement);
581 }
583 return wrapper;
584 }
586 function getListForElement(aElement) {
587 let wrapper = getWrapperForElement(aElement);
588 if (!wrapper)
589 return null;
591 let optionIndex = 0;
592 let result = {
593 multiple: wrapper.getMultiple(),
594 choices: []
595 };
597 // Build up a flat JSON array of the choices. In HTML, it's possible for select element choices
598 // to be under a group header (but not recursively). We distinguish between headers and entries
599 // using the boolean "list.group".
600 // XXX If possible, this would be a great candidate for tracing.
601 let children = wrapper.getChildren();
602 for (let i = 0; i < children.length; i++) {
603 let child = children[i];
604 if (wrapper.isGroup(child)) {
605 // This is the group element. Add an entry in the choices that says that the following
606 // elements are a member of this group.
607 result.choices.push({ group: true,
608 text: child.label || child.firstChild.data,
609 disabled: child.disabled
610 });
611 let subchildren = child.children;
612 for (let j = 0; j < subchildren.length; j++) {
613 let subchild = subchildren[j];
614 result.choices.push({
615 group: false,
616 inGroup: true,
617 text: wrapper.getText(subchild),
618 disabled: child.disabled || subchild.disabled,
619 selected: subchild.selected,
620 optionIndex: optionIndex++
621 });
622 }
623 }
624 else if (wrapper.isOption(child)) {
625 // This is a regular choice under no group.
626 result.choices.push({
627 group: false,
628 inGroup: false,
629 text: wrapper.getText(child),
630 disabled: child.disabled,
631 selected: child.selected,
632 optionIndex: optionIndex++
633 });
634 }
635 }
637 return result;
638 }
641 function SelectWrapper(aControl) {
642 this._control = aControl;
643 }
645 SelectWrapper.prototype = {
646 getSelectedIndex: function() {
647 return this._control.selectedIndex;
648 },
650 getMultiple: function() {
651 return this._control.multiple;
652 },
654 getOptions: function() {
655 return this._control.options;
656 },
658 getChildren: function() {
659 return this._control.children;
660 },
662 getText: function(aChild) {
663 return aChild.text;
664 },
666 isOption: function(aChild) {
667 return aChild instanceof HTMLOptionElement;
668 },
670 isGroup: function(aChild) {
671 return aChild instanceof HTMLOptGroupElement;
672 },
674 select: function(aIndex, aSelected) {
675 let options = this._control.options;
676 if (this.getMultiple())
677 options[aIndex].selected = aSelected;
678 else
679 options.selectedIndex = aIndex;
680 },
682 fireOnChange: function() {
683 let control = this._control;
684 let evt = this._control.ownerDocument.createEvent("Events");
685 evt.initEvent("change", true, true, this._control.ownerDocument.defaultView, 0,
686 false, false,
687 false, false, null);
688 content.setTimeout(function() {
689 control.dispatchEvent(evt);
690 }, 0);
691 }
692 };
693 this.SelectWrapper = SelectWrapper;
696 // bug 559792
697 // Use wrappedJSObject when control is in content for extra protection
698 function MenulistWrapper(aControl) {
699 this._control = aControl;
700 }
702 MenulistWrapper.prototype = {
703 getSelectedIndex: function() {
704 let control = this._control.wrappedJSObject || this._control;
705 let result = control.selectedIndex;
706 return ((typeof result == "number" && !isNaN(result)) ? result : -1);
707 },
709 getMultiple: function() {
710 return false;
711 },
713 getOptions: function() {
714 let control = this._control.wrappedJSObject || this._control;
715 return control.menupopup.children;
716 },
718 getChildren: function() {
719 let control = this._control.wrappedJSObject || this._control;
720 return control.menupopup.children;
721 },
723 getText: function(aChild) {
724 return aChild.label;
725 },
727 isOption: function(aChild) {
728 return aChild instanceof Ci.nsIDOMXULSelectControlItemElement;
729 },
731 isGroup: function(aChild) {
732 return false;
733 },
735 select: function(aIndex, aSelected) {
736 let control = this._control.wrappedJSObject || this._control;
737 control.selectedIndex = aIndex;
738 },
740 fireOnChange: function() {
741 let control = this._control;
742 let evt = document.createEvent("XULCommandEvent");
743 evt.initCommandEvent("command", true, true, window, 0,
744 false, false,
745 false, false, null);
746 content.setTimeout(function() {
747 control.dispatchEvent(evt);
748 }, 0);
749 }
750 };
751 this.MenulistWrapper = MenulistWrapper;