/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / /* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; dump("###################################### forms.js loaded\n"); let Ci = Components.interfaces; let Cc = Components.classes; let Cu = Components.utils; Cu.import("resource://gre/modules/Services.jsm"); Cu.import('resource://gre/modules/XPCOMUtils.jsm'); XPCOMUtils.defineLazyServiceGetter(Services, "fm", "@mozilla.org/focus-manager;1", "nsIFocusManager"); XPCOMUtils.defineLazyGetter(this, "domWindowUtils", function () { return content.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIDOMWindowUtils); }); const RESIZE_SCROLL_DELAY = 20; let HTMLDocument = Ci.nsIDOMHTMLDocument; let HTMLHtmlElement = Ci.nsIDOMHTMLHtmlElement; let HTMLBodyElement = Ci.nsIDOMHTMLBodyElement; let HTMLIFrameElement = Ci.nsIDOMHTMLIFrameElement; let HTMLInputElement = Ci.nsIDOMHTMLInputElement; let HTMLTextAreaElement = Ci.nsIDOMHTMLTextAreaElement; let HTMLSelectElement = Ci.nsIDOMHTMLSelectElement; let HTMLOptGroupElement = Ci.nsIDOMHTMLOptGroupElement; let HTMLOptionElement = Ci.nsIDOMHTMLOptionElement; let FormVisibility = { /** * Searches upwards in the DOM for an element that has been scrolled. * * @param {HTMLElement} node element to start search at. * @return {Window|HTMLElement|Null} null when none are found window/element otherwise. */ findScrolled: function fv_findScrolled(node) { let win = node.ownerDocument.defaultView; while (!(node instanceof HTMLBodyElement)) { // We can skip elements that have not been scrolled. // We only care about top now remember to add the scrollLeft // check if we decide to care about the X axis. if (node.scrollTop !== 0) { // the element has been scrolled so we may need to adjust // where we think the root element is located. // // Otherwise it may seem visible but be scrolled out of the viewport // inside this scrollable node. return node; } else { // this node does not effect where we think // the node is even if it is scrollable it has not hidden // the element we are looking for. node = node.parentNode; continue; } } // we also care about the window this is the more // common case where the content is larger then // the viewport/screen. if (win.scrollMaxX || win.scrollMaxY) { return win; } return null; }, /** * Checks if "top and "bottom" points of the position is visible. * * @param {Number} top position. * @param {Number} height of the element. * @param {Number} maxHeight of the window. * @return {Boolean} true when visible. */ yAxisVisible: function fv_yAxisVisible(top, height, maxHeight) { return (top > 0 && (top + height) < maxHeight); }, /** * Searches up through the dom for scrollable elements * which are not currently visible (relative to the viewport). * * @param {HTMLElement} element to start search at. * @param {Object} pos .top, .height and .width of element. */ scrollablesVisible: function fv_scrollablesVisible(element, pos) { while ((element = this.findScrolled(element))) { if (element.window && element.self === element) break; // remember getBoundingClientRect does not care // about scrolling only where the element starts // in the document. let offset = element.getBoundingClientRect(); // the top of both the scrollable area and // the form element itself are in the same document. // We adjust the "top" so if the elements coordinates // are relative to the viewport in the current document. let adjustedTop = pos.top - offset.top; let visible = this.yAxisVisible( adjustedTop, pos.height, pos.width ); if (!visible) return false; element = element.parentNode; } return true; }, /** * Verifies the element is visible in the viewport. * Handles scrollable areas, frames and scrollable viewport(s) (windows). * * @param {HTMLElement} element to verify. * @return {Boolean} true when visible. */ isVisible: function fv_isVisible(element) { // scrollable frames can be ignored we just care about iframes... let rect = element.getBoundingClientRect(); let parent = element.ownerDocument.defaultView; // used to calculate the inner position of frames / scrollables. // The intent was to use this information to scroll either up or down. // scrollIntoView(true) will _break_ some web content so we can't do // this today. If we want that functionality we need to manually scroll // the individual elements. let pos = { top: rect.top, height: rect.height, width: rect.width }; let visible = true; do { let frame = parent.frameElement; visible = visible && this.yAxisVisible(pos.top, pos.height, parent.innerHeight) && this.scrollablesVisible(element, pos); // nothing we can do about this now... // In the future we can use this information to scroll // only the elements we need to at this point as we should // have all the details we need to figure out how to scroll. if (!visible) return false; if (frame) { let frameRect = frame.getBoundingClientRect(); pos.top += frameRect.top + frame.clientTop; } } while ( (parent !== parent.parent) && (parent = parent.parent) ); return visible; } }; let FormAssistant = { init: function fa_init() { addEventListener("focus", this, true, false); addEventListener("blur", this, true, false); addEventListener("resize", this, true, false); addEventListener("submit", this, true, false); addEventListener("pagehide", this, true, false); addEventListener("beforeunload", this, true, false); addEventListener("input", this, true, false); addEventListener("keydown", this, true, false); addEventListener("keyup", this, true, false); addMessageListener("Forms:Select:Choice", this); addMessageListener("Forms:Input:Value", this); addMessageListener("Forms:Select:Blur", this); addMessageListener("Forms:SetSelectionRange", this); addMessageListener("Forms:ReplaceSurroundingText", this); addMessageListener("Forms:GetText", this); addMessageListener("Forms:Input:SendKey", this); addMessageListener("Forms:GetContext", this); addMessageListener("Forms:SetComposition", this); addMessageListener("Forms:EndComposition", this); }, ignoredInputTypes: new Set([ 'button', 'file', 'checkbox', 'radio', 'reset', 'submit', 'image', 'range' ]), isKeyboardOpened: false, selectionStart: -1, selectionEnd: -1, textBeforeCursor: "", textAfterCursor: "", scrollIntoViewTimeout: null, _focusedElement: null, _focusCounter: 0, // up one for every time we focus a new element _observer: null, _documentEncoder: null, _editor: null, _editing: false, _ignoreEditActionOnce: false, get focusedElement() { if (this._focusedElement && Cu.isDeadWrapper(this._focusedElement)) this._focusedElement = null; return this._focusedElement; }, set focusedElement(val) { this._focusCounter++; this._focusedElement = val; }, setFocusedElement: function fa_setFocusedElement(element) { let self = this; if (element === this.focusedElement) return; if (this.focusedElement) { this.focusedElement.removeEventListener('mousedown', this); this.focusedElement.removeEventListener('mouseup', this); this.focusedElement.removeEventListener('compositionend', this); if (this._observer) { this._observer.disconnect(); this._observer = null; } if (!element) { this.focusedElement.blur(); } } this._documentEncoder = null; if (this._editor) { // When the nsIFrame of the input element is reconstructed by // CSS restyling, the editor observers are removed. Catch // [nsIEditor.removeEditorObserver] failure exception if that // happens. try { this._editor.removeEditorObserver(this); } catch (e) {} this._editor = null; } if (element) { element.addEventListener('mousedown', this); element.addEventListener('mouseup', this); element.addEventListener('compositionend', this); if (isContentEditable(element)) { this._documentEncoder = getDocumentEncoder(element); } this._editor = getPlaintextEditor(element); if (this._editor) { // Add a nsIEditorObserver to monitor the text content of the focused // element. this._editor.addEditorObserver(this); } // If our focusedElement is removed from DOM we want to handle it properly let MutationObserver = element.ownerDocument.defaultView.MutationObserver; this._observer = new MutationObserver(function(mutations) { var del = [].some.call(mutations, function(m) { return [].some.call(m.removedNodes, function(n) { return n === element; }); }); if (del && element === self.focusedElement) { // item was deleted, fake a blur so all state gets set correctly self.handleEvent({ target: element, type: "blur" }); } }); this._observer.observe(element.parentNode, { childList: true }); } this.focusedElement = element; }, get documentEncoder() { return this._documentEncoder; }, // Get the nsIPlaintextEditor object of current input field. get editor() { return this._editor; }, // Implements nsIEditorObserver get notification when the text content of // current input field has changed. EditAction: function fa_editAction() { if (this._editing) { return; } else if (this._ignoreEditActionOnce) { this._ignoreEditActionOnce = false; return; } this.sendKeyboardState(this.focusedElement); }, handleEvent: function fa_handleEvent(evt) { let target = evt.target; let range = null; switch (evt.type) { case "focus": if (!target) { break; } // Focusing on Window, Document or iFrame should focus body if (target instanceof HTMLHtmlElement) { target = target.document.body; } else if (target instanceof HTMLDocument) { target = target.body; } else if (target instanceof HTMLIFrameElement) { target = target.contentDocument ? target.contentDocument.body : null; } if (!target) { break; } if (isContentEditable(target)) { this.showKeyboard(this.getTopLevelEditable(target)); this.updateSelection(); break; } if (this.isFocusableElement(target)) { this.showKeyboard(target); this.updateSelection(); } break; case "pagehide": case "beforeunload": // We are only interested to the pagehide and beforeunload events from // the root document. if (target && target != content.document) { break; } // fall through case "blur": case "submit": if (this.focusedElement) { this.hideKeyboard(); this.selectionStart = -1; this.selectionEnd = -1; } break; case 'mousedown': if (!this.focusedElement) { break; } // We only listen for this event on the currently focused element. // When the mouse goes down, note the cursor/selection position this.updateSelection(); break; case 'mouseup': if (!this.focusedElement) { break; } // We only listen for this event on the currently focused element. // When the mouse goes up, see if the cursor has moved (or the // selection changed) since the mouse went down. If it has, we // need to tell the keyboard about it range = getSelectionRange(this.focusedElement); if (range[0] !== this.selectionStart || range[1] !== this.selectionEnd) { this.sendKeyboardState(this.focusedElement); this.updateSelection(); } break; case "resize": if (!this.isKeyboardOpened) return; if (this.scrollIntoViewTimeout) { content.clearTimeout(this.scrollIntoViewTimeout); this.scrollIntoViewTimeout = null; } // We may receive multiple resize events in quick succession, so wait // a bit before scrolling the input element into view. if (this.focusedElement) { this.scrollIntoViewTimeout = content.setTimeout(function () { this.scrollIntoViewTimeout = null; if (this.focusedElement && !FormVisibility.isVisible(this.focusedElement)) { this.focusedElement.scrollIntoView(false); } }.bind(this), RESIZE_SCROLL_DELAY); } break; case "input": if (this.focusedElement) { // When the text content changes, notify the keyboard this.updateSelection(); } break; case "keydown": if (!this.focusedElement) { break; } CompositionManager.endComposition(''); // Don't monitor the text change resulting from key event. this._ignoreEditActionOnce = true; // We use 'setTimeout' to wait until the input element accomplishes the // change in selection range. content.setTimeout(function() { this.updateSelection(); }.bind(this), 0); break; case "keyup": if (!this.focusedElement) { break; } CompositionManager.endComposition(''); this._ignoreEditActionOnce = false; break; case "compositionend": if (!this.focusedElement) { break; } CompositionManager.onCompositionEnd(); break; } }, receiveMessage: function fa_receiveMessage(msg) { let target = this.focusedElement; let json = msg.json; // To not break mozKeyboard contextId is optional if ('contextId' in json && json.contextId !== this._focusCounter && json.requestId) { // Ignore messages that are meant for a previously focused element sendAsyncMessage("Forms:SequenceError", { requestId: json.requestId, error: "Expected contextId " + this._focusCounter + " but was " + json.contextId }); return; } if (!target) { switch (msg.name) { case "Forms:GetText": sendAsyncMessage("Forms:GetText:Result:Error", { requestId: json.requestId, error: "No focused element" }); break; } return; } this._editing = true; switch (msg.name) { case "Forms:Input:Value": { CompositionManager.endComposition(''); target.value = json.value; let event = target.ownerDocument.createEvent('HTMLEvents'); event.initEvent('input', true, false); target.dispatchEvent(event); break; } case "Forms:Input:SendKey": CompositionManager.endComposition(''); ["keydown", "keypress", "keyup"].forEach(function(type) { domWindowUtils.sendKeyEvent(type, json.keyCode, json.charCode, json.modifiers); }); if (json.requestId) { sendAsyncMessage("Forms:SendKey:Result:OK", { requestId: json.requestId }); } break; case "Forms:Select:Choice": let options = target.options; let valueChanged = false; if ("index" in json) { if (options.selectedIndex != json.index) { options.selectedIndex = json.index; valueChanged = true; } } else if ("indexes" in json) { for (let i = 0; i < options.length; i++) { let newValue = (json.indexes.indexOf(i) != -1); if (options.item(i).selected != newValue) { options.item(i).selected = newValue; valueChanged = true; } } } // only fire onchange event if any selected option is changed if (valueChanged) { let event = target.ownerDocument.createEvent('HTMLEvents'); event.initEvent('change', true, true); target.dispatchEvent(event); } break; case "Forms:Select:Blur": { this.setFocusedElement(null); break; } case "Forms:SetSelectionRange": { CompositionManager.endComposition(''); let start = json.selectionStart; let end = json.selectionEnd; setSelectionRange(target, start, end); this.updateSelection(); if (json.requestId) { sendAsyncMessage("Forms:SetSelectionRange:Result:OK", { requestId: json.requestId, selectioninfo: this.getSelectionInfo() }); } break; } case "Forms:ReplaceSurroundingText": { CompositionManager.endComposition(''); let text = json.text; let beforeLength = json.beforeLength; let afterLength = json.afterLength; let selectionRange = getSelectionRange(target); replaceSurroundingText(target, text, selectionRange[0], beforeLength, afterLength); if (json.requestId) { sendAsyncMessage("Forms:ReplaceSurroundingText:Result:OK", { requestId: json.requestId, selectioninfo: this.getSelectionInfo() }); } break; } case "Forms:GetText": { let value = isContentEditable(target) ? getContentEditableText(target) : target.value; if (json.offset && json.length) { value = value.substr(json.offset, json.length); } else if (json.offset) { value = value.substr(json.offset); } sendAsyncMessage("Forms:GetText:Result:OK", { requestId: json.requestId, text: value }); break; } case "Forms:GetContext": { let obj = getJSON(target, this._focusCounter); sendAsyncMessage("Forms:GetContext:Result:OK", obj); break; } case "Forms:SetComposition": { CompositionManager.setComposition(target, json.text, json.cursor, json.clauses); sendAsyncMessage("Forms:SetComposition:Result:OK", { requestId: json.requestId, }); break; } case "Forms:EndComposition": { CompositionManager.endComposition(json.text); sendAsyncMessage("Forms:EndComposition:Result:OK", { requestId: json.requestId, }); break; } } this._editing = false; }, showKeyboard: function fa_showKeyboard(target) { if (this.focusedElement === target) return; if (target instanceof HTMLOptionElement) target = target.parentNode; this.setFocusedElement(target); let kbOpened = this.sendKeyboardState(target); if (this.isTextInputElement(target)) this.isKeyboardOpened = kbOpened; }, hideKeyboard: function fa_hideKeyboard() { sendAsyncMessage("Forms:Input", { "type": "blur" }); this.isKeyboardOpened = false; this.setFocusedElement(null); }, isFocusableElement: function fa_isFocusableElement(element) { if (element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) return true; if (element instanceof HTMLOptionElement && element.parentNode instanceof HTMLSelectElement) return true; return (element instanceof HTMLInputElement && !this.ignoredInputTypes.has(element.type)); }, isTextInputElement: function fa_isTextInputElement(element) { return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || isContentEditable(element); }, getTopLevelEditable: function fa_getTopLevelEditable(element) { function retrieveTopLevelEditable(element) { while (element && !isContentEditable(element)) element = element.parentNode; return element; } return retrieveTopLevelEditable(element) || element; }, sendKeyboardState: function(element) { // FIXME/bug 729623: work around apparent bug in the IME manager // in gecko. let readonly = element.getAttribute("readonly"); if (readonly) { return false; } sendAsyncMessage("Forms:Input", getJSON(element, this._focusCounter)); return true; }, getSelectionInfo: function fa_getSelectionInfo() { let element = this.focusedElement; let range = getSelectionRange(element); let text = isContentEditable(element) ? getContentEditableText(element) : element.value; let textAround = getTextAroundCursor(text, range); let changed = this.selectionStart !== range[0] || this.selectionEnd !== range[1] || this.textBeforeCursor !== textAround.before || this.textAfterCursor !== textAround.after; this.selectionStart = range[0]; this.selectionEnd = range[1]; this.textBeforeCursor = textAround.before; this.textAfterCursor = textAround.after; return { selectionStart: range[0], selectionEnd: range[1], textBeforeCursor: textAround.before, textAfterCursor: textAround.after, changed: changed }; }, // Notify when the selection range changes updateSelection: function fa_updateSelection() { if (!this.focusedElement) { return; } let selectionInfo = this.getSelectionInfo(); if (selectionInfo.changed) { sendAsyncMessage("Forms:SelectionChange", this.getSelectionInfo()); } } }; FormAssistant.init(); function isContentEditable(element) { if (!element) { return false; } if (element.isContentEditable || element.designMode == "on") return true; return element.ownerDocument && element.ownerDocument.designMode == "on"; } function isPlainTextField(element) { if (!element) { return false; } return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement; } function getJSON(element, focusCounter) { let type = element.type || ""; let value = element.value || ""; let max = element.max || ""; let min = element.min || ""; // Treat contenteditble element as a special text area field if (isContentEditable(element)) { type = "textarea"; value = getContentEditableText(element); } // Until the input type=date/datetime/range have been implemented // let's return their real type even if the platform returns 'text' let attributeType = element.getAttribute("type") || ""; if (attributeType) { var typeLowerCase = attributeType.toLowerCase(); switch (typeLowerCase) { case "datetime": case "datetime-local": case "range": type = typeLowerCase; break; } } // Gecko has some support for @inputmode but behind a preference and // it is disabled by default. // Gaia is then using @x-inputmode has its proprietary way to set // inputmode for fields. This shouldn't be used outside of pre-installed // apps because the attribute is going to disappear as soon as a definitive // solution will be find. let inputmode = element.getAttribute('x-inputmode'); if (inputmode) { inputmode = inputmode.toLowerCase(); } else { inputmode = ''; } let range = getSelectionRange(element); let textAround = getTextAroundCursor(value, range); return { "contextId": focusCounter, "type": type.toLowerCase(), "choices": getListForElement(element), "value": value, "inputmode": inputmode, "selectionStart": range[0], "selectionEnd": range[1], "max": max, "min": min, "lang": element.lang || "", "textBeforeCursor": textAround.before, "textAfterCursor": textAround.after }; } function getTextAroundCursor(value, range) { let textBeforeCursor = range[0] < 100 ? value.substr(0, range[0]) : value.substr(range[0] - 100, 100); let textAfterCursor = range[1] + 100 > value.length ? value.substr(range[0], value.length) : value.substr(range[0], range[1] - range[0] + 100); return { before: textBeforeCursor, after: textAfterCursor }; } function getListForElement(element) { if (!(element instanceof HTMLSelectElement)) return null; let optionIndex = 0; let result = { "multiple": element.multiple, "choices": [] }; // Build up a flat JSON array of the choices. // In HTML, it's possible for select element choices to be under a // group header (but not recursively). We distinguish between headers // and entries using the boolean "list.group". let children = element.children; for (let i = 0; i < children.length; i++) { let child = children[i]; if (child instanceof HTMLOptGroupElement) { result.choices.push({ "group": true, "text": child.label || child.firstChild.data, "disabled": child.disabled }); let subchildren = child.children; for (let j = 0; j < subchildren.length; j++) { let subchild = subchildren[j]; result.choices.push({ "group": false, "inGroup": true, "text": subchild.text, "disabled": child.disabled || subchild.disabled, "selected": subchild.selected, "optionIndex": optionIndex++ }); } } else if (child instanceof HTMLOptionElement) { result.choices.push({ "group": false, "inGroup": false, "text": child.text, "disabled": child.disabled, "selected": child.selected, "optionIndex": optionIndex++ }); } } return result; }; // Create a plain text document encode from the focused element. function getDocumentEncoder(element) { let encoder = Cc["@mozilla.org/layout/documentEncoder;1?type=text/plain"] .createInstance(Ci.nsIDocumentEncoder); let flags = Ci.nsIDocumentEncoder.SkipInvisibleContent | Ci.nsIDocumentEncoder.OutputRaw | // Bug 902847. Don't trim trailing spaces of a line. Ci.nsIDocumentEncoder.OutputDontRemoveLineEndingSpaces | Ci.nsIDocumentEncoder.OutputLFLineBreak | Ci.nsIDocumentEncoder.OutputNonTextContentAsPlaceholder; encoder.init(element.ownerDocument, "text/plain", flags); return encoder; } // Get the visible content text of a content editable element function getContentEditableText(element) { if (!element || !isContentEditable(element)) { return null; } let doc = element.ownerDocument; let range = doc.createRange(); range.selectNodeContents(element); let encoder = FormAssistant.documentEncoder; encoder.setRange(range); return encoder.encodeToString(); } function getSelectionRange(element) { let start = 0; let end = 0; if (isPlainTextField(element)) { // Get the selection range of and