diff options
Diffstat (limited to 'dom/inputmethod/forms.js')
-rw-r--r-- | dom/inputmethod/forms.js | 1561 |
1 files changed, 1561 insertions, 0 deletions
diff --git a/dom/inputmethod/forms.js b/dom/inputmethod/forms.js new file mode 100644 index 000000000..1884f2b4d --- /dev/null +++ b/dom/inputmethod/forms.js @@ -0,0 +1,1561 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 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"); + +var Ci = Components.interfaces; +var Cc = Components.classes; +var Cu = Components.utils; +var Cr = Components.results; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import('resource://gre/modules/XPCOMUtils.jsm'); +XPCOMUtils.defineLazyServiceGetter(Services, "fm", + "@mozilla.org/focus-manager;1", + "nsIFocusManager"); + +/* + * A WeakMap to map window to objects keeping it's TextInputProcessor instance. + */ +var WindowMap = { + // WeakMap of <window, object> pairs. + _map: null, + + /* + * Set the object associated to the window and return it. + */ + _getObjForWin: function(win) { + if (!this._map) { + this._map = new WeakMap(); + } + if (this._map.has(win)) { + return this._map.get(win); + } else { + let obj = { + tip: null + }; + this._map.set(win, obj); + + return obj; + } + }, + + getTextInputProcessor: function(win) { + if (!win) { + return; + } + let obj = this._getObjForWin(win); + let tip = obj.tip + + if (!tip) { + tip = obj.tip = Cc["@mozilla.org/text-input-processor;1"] + .createInstance(Ci.nsITextInputProcessor); + } + + if (!tip.beginInputTransaction(win, textInputProcessorCallback)) { + tip = obj.tip = null; + } + return tip; + } +}; + +const RESIZE_SCROLL_DELAY = 20; +// In content editable node, when there are hidden elements such as <br>, it +// may need more than one (usually less than 3 times) move/extend operations +// to change the selection range. If we cannot change the selection range +// with more than 20 opertations, we are likely being blocked and cannot change +// the selection range any more. +const MAX_BLOCKED_COUNT = 20; + +var HTMLDocument = Ci.nsIDOMHTMLDocument; +var HTMLHtmlElement = Ci.nsIDOMHTMLHtmlElement; +var HTMLBodyElement = Ci.nsIDOMHTMLBodyElement; +var HTMLIFrameElement = Ci.nsIDOMHTMLIFrameElement; +var HTMLInputElement = Ci.nsIDOMHTMLInputElement; +var HTMLTextAreaElement = Ci.nsIDOMHTMLTextAreaElement; +var HTMLSelectElement = Ci.nsIDOMHTMLSelectElement; +var HTMLOptGroupElement = Ci.nsIDOMHTMLOptGroupElement; +var HTMLOptionElement = Ci.nsIDOMHTMLOptionElement; + +function guessKeyNameFromKeyCode(KeyboardEvent, aKeyCode) { + switch (aKeyCode) { + case KeyboardEvent.DOM_VK_CANCEL: + return "Cancel"; + case KeyboardEvent.DOM_VK_HELP: + return "Help"; + case KeyboardEvent.DOM_VK_BACK_SPACE: + return "Backspace"; + case KeyboardEvent.DOM_VK_TAB: + return "Tab"; + case KeyboardEvent.DOM_VK_CLEAR: + return "Clear"; + case KeyboardEvent.DOM_VK_RETURN: + return "Enter"; + case KeyboardEvent.DOM_VK_SHIFT: + return "Shift"; + case KeyboardEvent.DOM_VK_CONTROL: + return "Control"; + case KeyboardEvent.DOM_VK_ALT: + return "Alt"; + case KeyboardEvent.DOM_VK_PAUSE: + return "Pause"; + case KeyboardEvent.DOM_VK_EISU: + return "Eisu"; + case KeyboardEvent.DOM_VK_ESCAPE: + return "Escape"; + case KeyboardEvent.DOM_VK_CONVERT: + return "Convert"; + case KeyboardEvent.DOM_VK_NONCONVERT: + return "NonConvert"; + case KeyboardEvent.DOM_VK_ACCEPT: + return "Accept"; + case KeyboardEvent.DOM_VK_MODECHANGE: + return "ModeChange"; + case KeyboardEvent.DOM_VK_PAGE_UP: + return "PageUp"; + case KeyboardEvent.DOM_VK_PAGE_DOWN: + return "PageDown"; + case KeyboardEvent.DOM_VK_END: + return "End"; + case KeyboardEvent.DOM_VK_HOME: + return "Home"; + case KeyboardEvent.DOM_VK_LEFT: + return "ArrowLeft"; + case KeyboardEvent.DOM_VK_UP: + return "ArrowUp"; + case KeyboardEvent.DOM_VK_RIGHT: + return "ArrowRight"; + case KeyboardEvent.DOM_VK_DOWN: + return "ArrowDown"; + case KeyboardEvent.DOM_VK_SELECT: + return "Select"; + case KeyboardEvent.DOM_VK_PRINT: + return "Print"; + case KeyboardEvent.DOM_VK_EXECUTE: + return "Execute"; + case KeyboardEvent.DOM_VK_PRINTSCREEN: + return "PrintScreen"; + case KeyboardEvent.DOM_VK_INSERT: + return "Insert"; + case KeyboardEvent.DOM_VK_DELETE: + return "Delete"; + case KeyboardEvent.DOM_VK_WIN: + return "OS"; + case KeyboardEvent.DOM_VK_CONTEXT_MENU: + return "ContextMenu"; + case KeyboardEvent.DOM_VK_SLEEP: + return "Standby"; + case KeyboardEvent.DOM_VK_F1: + return "F1"; + case KeyboardEvent.DOM_VK_F2: + return "F2"; + case KeyboardEvent.DOM_VK_F3: + return "F3"; + case KeyboardEvent.DOM_VK_F4: + return "F4"; + case KeyboardEvent.DOM_VK_F5: + return "F5"; + case KeyboardEvent.DOM_VK_F6: + return "F6"; + case KeyboardEvent.DOM_VK_F7: + return "F7"; + case KeyboardEvent.DOM_VK_F8: + return "F8"; + case KeyboardEvent.DOM_VK_F9: + return "F9"; + case KeyboardEvent.DOM_VK_F10: + return "F10"; + case KeyboardEvent.DOM_VK_F11: + return "F11"; + case KeyboardEvent.DOM_VK_F12: + return "F12"; + case KeyboardEvent.DOM_VK_F13: + return "F13"; + case KeyboardEvent.DOM_VK_F14: + return "F14"; + case KeyboardEvent.DOM_VK_F15: + return "F15"; + case KeyboardEvent.DOM_VK_F16: + return "F16"; + case KeyboardEvent.DOM_VK_F17: + return "F17"; + case KeyboardEvent.DOM_VK_F18: + return "F18"; + case KeyboardEvent.DOM_VK_F19: + return "F19"; + case KeyboardEvent.DOM_VK_F20: + return "F20"; + case KeyboardEvent.DOM_VK_F21: + return "F21"; + case KeyboardEvent.DOM_VK_F22: + return "F22"; + case KeyboardEvent.DOM_VK_F23: + return "F23"; + case KeyboardEvent.DOM_VK_F24: + return "F24"; + case KeyboardEvent.DOM_VK_NUM_LOCK: + return "NumLock"; + case KeyboardEvent.DOM_VK_SCROLL_LOCK: + return "ScrollLock"; + case KeyboardEvent.DOM_VK_VOLUME_MUTE: + return "AudioVolumeMute"; + case KeyboardEvent.DOM_VK_VOLUME_DOWN: + return "AudioVolumeDown"; + case KeyboardEvent.DOM_VK_VOLUME_UP: + return "AudioVolumeUp"; + case KeyboardEvent.DOM_VK_META: + return "Meta"; + case KeyboardEvent.DOM_VK_ALTGR: + return "AltGraph"; + case KeyboardEvent.DOM_VK_ATTN: + return "Attn"; + case KeyboardEvent.DOM_VK_CRSEL: + return "CrSel"; + case KeyboardEvent.DOM_VK_EXSEL: + return "ExSel"; + case KeyboardEvent.DOM_VK_EREOF: + return "EraseEof"; + case KeyboardEvent.DOM_VK_PLAY: + return "Play"; + default: + return "Unidentified"; + } +} + +var 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.scrollMinX || win.scrollMaxY != win.scrollMinY) { + 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, + offset.height + ); + + 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; + } +}; + +// This object implements nsITextInputProcessorCallback +var textInputProcessorCallback = { + onNotify: function(aTextInputProcessor, aNotification) { + try { + switch (aNotification.type) { + case "request-to-commit": + // TODO: Send a notification through asyncMessage to the keyboard here. + aTextInputProcessor.commitComposition(); + + break; + case "request-to-cancel": + // TODO: Send a notification through asyncMessage to the keyboard here. + aTextInputProcessor.cancelComposition(); + + break; + + case "notify-detached": + // TODO: Send a notification through asyncMessage to the keyboard here. + break; + + // TODO: Manage _focusedElement for text input from here instead. + // (except for <select> which will be need to handled elsewhere) + case "notify-focus": + break; + + case "notify-blur": + break; + } + } catch (e) { + return false; + } + return true; + } +}; + +var FormAssistant = { + init: function fa_init() { + addEventListener("focus", this, true, false); + addEventListener("blur", this, true, false); + addEventListener("resize", this, true, false); + // We should not blur the fucus if the submit event is cancelled, + // therefore we are binding our event listener in the bubbling phase here. + addEventListener("submit", this, false, 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: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' + ]), + + isHandlingFocus: false, + selectionStart: -1, + selectionEnd: -1, + text: "", + + scrollIntoViewTimeout: null, + _focusedElement: null, + _focusCounter: 0, // up one for every time we focus a new element + _focusDeleteObserver: null, + _focusContentObserver: null, + _documentEncoder: null, + _editor: null, + _editing: false, + _selectionPrivate: null, + + 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('compositionend', this); + if (this._focusDeleteObserver) { + this._focusDeleteObserver.disconnect(); + this._focusDeleteObserver = null; + } + if (this._focusContentObserver) { + this._focusContentObserver.disconnect(); + this._focusContentObserver = null; + } + if (this._selectionPrivate) { + this._selectionPrivate.removeSelectionListener(this); + this._selectionPrivate = null; + } + } + + 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('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); + + let selection = this._editor.selection; + if (selection) { + this._selectionPrivate = selection.QueryInterface(Ci.nsISelectionPrivate); + this._selectionPrivate.addSelectionListener(this); + } + } + + // If our focusedElement is removed from DOM we want to handle it properly + let MutationObserver = element.ownerDocument.defaultView.MutationObserver; + this._focusDeleteObserver = new MutationObserver(function(mutations) { + var del = [].some.call(mutations, function(m) { + return [].some.call(m.removedNodes, function(n) { + return n.contains(element); + }); + }); + if (del && element === self.focusedElement) { + self.unhandleFocus(); + } + }); + + this._focusDeleteObserver.observe(element.ownerDocument.body, { + childList: true, + subtree: true + }); + + // If contenteditable, also add a mutation observer on its content and + // call selectionChanged when a change occurs + if (isContentEditable(element)) { + this._focusContentObserver = new MutationObserver(function() { + this.updateSelection(); + }.bind(this)); + + this._focusContentObserver.observe(element, { + childList: true, + subtree: true + }); + } + } + + this.focusedElement = element; + }, + + notifySelectionChanged: function(aDocument, aSelection, aReason) { + this.updateSelection(); + }, + + 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 || !this.isHandlingFocus) { + return; + } + this.sendInputState(this.focusedElement); + }, + + handleEvent: function fa_handleEvent(evt) { + let target = evt.composedTarget; + + 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.handleFocus(this.getTopLevelEditable(target)); + this.updateSelection(); + break; + } + + if (this.isFocusableElement(target)) { + this.handleFocus(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 "submit": + if (this.focusedElement && !evt.defaultPrevented) { + this.focusedElement.blur(); + } + break; + + case "blur": + if (this.focusedElement) { + this.unhandleFocus(); + } + break; + + case "resize": + if (!this.isHandlingFocus) + 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)) { + scrollSelectionOrElementIntoView(this.focusedElement); + } + }.bind(this), RESIZE_SCROLL_DELAY); + } + break; + + case "keydown": + if (!this.focusedElement || this._editing) { + break; + } + + CompositionManager.endComposition(''); + break; + + case "keyup": + if (!this.focusedElement || this._editing) { + break; + } + + CompositionManager.endComposition(''); + 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) { + 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(''); + + let win = target.ownerDocument.defaultView; + let tip = WindowMap.getTextInputProcessor(win); + if (!tip) { + if (json.requestId) { + sendAsyncMessage("Forms:SendKey:Result:Error", { + requestId: json.requestId, + error: "Unable to start input transaction." + }); + } + + break; + } + + // If we receive a keyboardEventDict from json, that means the user + // is calling the method with the new arguments. + // Otherwise, we would have to construct our own keyboardEventDict + // based on legacy values we have received. + let keyboardEventDict = json.keyboardEventDict; + let flags = 0; + + if (keyboardEventDict) { + if ('flags' in keyboardEventDict) { + flags = keyboardEventDict.flags; + } + } else { + // The naive way to figure out if the key to dispatch is printable. + let printable = !!json.charCode; + + // For printable keys, the value should be the actual character. + // For non-printable keys, it should be a value in the D3E spec. + // Here we make some educated guess for it. + let key = printable ? + String.fromCharCode(json.charCode) : + guessKeyNameFromKeyCode(win.KeyboardEvent, json.keyCode); + + // keyCode from content is only respected when the key is not an + // an alphanumeric character. We also ask TextInputProcessor not to + // infer this value for non-printable keys to keep the original + // behavior. + let keyCode = (printable && /^[a-zA-Z0-9]$/.test(key)) ? + key.toUpperCase().charCodeAt(0) : + json.keyCode; + + keyboardEventDict = { + key: key, + keyCode: keyCode, + // We don't have any information to tell the virtual key the + // user have interacted with. + code: "", + // We do not have the information to infer location of the virtual key + // either (and we would need TextInputProcessor not to compute it). + location: 0, + // This indicates the key is triggered for repeats. + repeat: json.repeat + }; + + flags = tip.KEY_KEEP_KEY_LOCATION_STANDARD; + if (!printable) { + flags |= tip.KEY_NON_PRINTABLE_KEY; + } + if (!keyboardEventDict.keyCode) { + flags |= tip.KEY_KEEP_KEYCODE_ZERO; + } + } + + let keyboardEvent = new win.KeyboardEvent("", keyboardEventDict); + + let keydownDefaultPrevented = false; + try { + switch (json.method) { + case 'sendKey': { + let consumedFlags = tip.keydown(keyboardEvent, flags); + keydownDefaultPrevented = + !!(tip.KEYDOWN_IS_CONSUMED & consumedFlags); + if (!keyboardEventDict.repeat) { + tip.keyup(keyboardEvent, flags); + } + break; + } + case 'keydown': { + let consumedFlags = tip.keydown(keyboardEvent, flags); + keydownDefaultPrevented = + !!(tip.KEYDOWN_IS_CONSUMED & consumedFlags); + break; + } + case 'keyup': { + tip.keyup(keyboardEvent, flags); + + break; + } + } + } catch (err) { + dump("forms.js:" + err.toString() + "\n"); + + if (json.requestId) { + if (err instanceof Ci.nsIException && + err.result == Cr.NS_ERROR_ILLEGAL_VALUE) { + sendAsyncMessage("Forms:SendKey:Result:Error", { + requestId: json.requestId, + error: "The values specified are illegal." + }); + } else { + sendAsyncMessage("Forms:SendKey:Result:Error", { + requestId: json.requestId, + error: "Unable to type into destroyed input." + }); + } + } + + break; + } + + if (json.requestId) { + if (keydownDefaultPrevented) { + sendAsyncMessage("Forms:SendKey:Result:Error", { + requestId: json.requestId, + error: "Key event(s) was cancelled." + }); + } else { + sendAsyncMessage("Forms:SendKey:Result:OK", { + requestId: json.requestId, + selectioninfo: this.getSelectionInfo() + }); + } + } + + 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": { + if (this.focusedElement) { + this.focusedElement.blur(); + } + + break; + } + + case "Forms:SetSelectionRange": { + CompositionManager.endComposition(''); + + let start = json.selectionStart; + let end = json.selectionEnd; + + if (!setSelectionRange(target, start, end)) { + if (json.requestId) { + sendAsyncMessage("Forms:SetSelectionRange:Result:Error", { + requestId: json.requestId, + error: "failed" + }); + } + break; + } + + if (json.requestId) { + sendAsyncMessage("Forms:SetSelectionRange:Result:OK", { + requestId: json.requestId, + selectioninfo: this.getSelectionInfo() + }); + } + break; + } + + case "Forms:ReplaceSurroundingText": { + CompositionManager.endComposition(''); + + if (!replaceSurroundingText(target, + json.text, + json.offset, + json.length)) { + if (json.requestId) { + sendAsyncMessage("Forms:ReplaceSurroundingText:Result:Error", { + requestId: json.requestId, + error: "failed" + }); + } + break; + } + + if (json.requestId) { + sendAsyncMessage("Forms:ReplaceSurroundingText:Result:OK", { + requestId: json.requestId, + selectioninfo: this.getSelectionInfo() + }); + } + 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, json.keyboardEventDict); + sendAsyncMessage("Forms:SetComposition:Result:OK", { + requestId: json.requestId, + selectioninfo: this.getSelectionInfo() + }); + break; + } + + case "Forms:EndComposition": { + CompositionManager.endComposition(json.text, json.keyboardEventDict); + sendAsyncMessage("Forms:EndComposition:Result:OK", { + requestId: json.requestId, + selectioninfo: this.getSelectionInfo() + }); + break; + } + } + this._editing = false; + + }, + + handleFocus: function fa_handleFocus(target) { + if (this.focusedElement === target) + return; + + if (target instanceof HTMLOptionElement) + target = target.parentNode; + + this.setFocusedElement(target); + this.sendInputState(target); + this.isHandlingFocus = true; + }, + + unhandleFocus: function fa_unhandleFocus() { + this.setFocusedElement(null); + this.isHandlingFocus = false; + this.selectionStart = -1; + this.selectionEnd = -1; + this.text = ""; + sendAsyncMessage("Forms:Blur", {}); + }, + + 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) && + !element.readOnly); + }, + + getTopLevelEditable: function fa_getTopLevelEditable(element) { + function retrieveTopLevelEditable(element) { + while (element && !isContentEditable(element)) + element = element.parentNode; + + return element; + } + + return retrieveTopLevelEditable(element) || element; + }, + + sendInputState: function(element) { + sendAsyncMessage("Forms:Focus", getJSON(element, this._focusCounter)); + }, + + getSelectionInfo: function fa_getSelectionInfo() { + let element = this.focusedElement; + let range = getSelectionRange(element); + + let text = isContentEditable(element) ? getContentEditableText(element) + : element.value; + + let changed = this.selectionStart !== range[0] || + this.selectionEnd !== range[1] || + this.text !== text; + + this.selectionStart = range[0]; + this.selectionEnd = range[1]; + this.text = text; + + return { + selectionStart: range[0], + selectionEnd: range[1], + text: text, + changed: changed + }; + }, + + _selectionTimeout: null, + + // Notify when the selection range changes + updateSelection: function fa_updateSelection() { + // A call to setSelectionRange on input field causes 2 selection changes + // one to [0,0] and one to actual value. Both are sent in same tick. + // Prevent firing two events in that scenario, always only use the last 1. + // + // It is also a workaround for Bug 1053048, which prevents + // getSelectionInfo() accessing selectionStart or selectionEnd in the + // callback function of nsISelectionListener::NotifySelectionChanged(). + if (this._selectionTimeout) { + content.clearTimeout(this._selectionTimeout); + } + this._selectionTimeout = content.setTimeout(function() { + if (!this.focusedElement) { + return; + } + let selectionInfo = this.getSelectionInfo(); + if (selectionInfo.changed) { + sendAsyncMessage("Forms:SelectionChange", selectionInfo); + } + }.bind(this), 0); + } +}; + +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 HTMLTextAreaElement || + (element instanceof HTMLInputElement && + element.mozIsTextField(false)); +} + +function getJSON(element, focusCounter) { + // <input type=number> has a nested anonymous <input type=text> element that + // takes focus on behalf of the number control when someone tries to focus + // the number control. If |element| is such an anonymous text control then we + // need it's number control here in order to get the correct 'type' etc.: + element = element.ownerNumberControl || element; + + let type = element.tagName.toLowerCase(); + let inputType = (element.type || "").toLowerCase(); + let value = element.value || ""; + let max = element.max || ""; + let min = element.min || ""; + + // Treat contenteditable element as a special text area field + if (isContentEditable(element)) { + type = "contenteditable"; + inputType = "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 attributeInputType = element.getAttribute("type") || ""; + + if (attributeInputType) { + let inputTypeLowerCase = attributeInputType.toLowerCase(); + switch (inputTypeLowerCase) { + case "datetime": + case "datetime-local": + case "month": + case "week": + case "range": + inputType = inputTypeLowerCase; + 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); + + return { + "contextId": focusCounter, + + "type": type, + "inputType": inputType, + "inputMode": inputMode, + + "choices": getListForElement(element), + "value": value, + "selectionStart": range[0], + "selectionEnd": range[1], + "max": max, + "min": min, + "lang": element.lang || "" + }; +} + +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 | + Ci.nsIDocumentEncoder.OutputDropInvisibleBreak | + // 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 <input> and <textarea> elements + start = element.selectionStart; + end = element.selectionEnd; + } else if (isContentEditable(element)){ + // Get the selection range of contenteditable elements + let win = element.ownerDocument.defaultView; + let sel = win.getSelection(); + if (sel && sel.rangeCount > 0) { + start = getContentEditableSelectionStart(element, sel); + end = start + getContentEditableSelectionLength(element, sel); + } else { + dump("Failed to get window.getSelection()\n"); + } + } + return [start, end]; + } + +function getContentEditableSelectionStart(element, selection) { + let doc = element.ownerDocument; + let range = doc.createRange(); + range.setStart(element, 0); + range.setEnd(selection.anchorNode, selection.anchorOffset); + let encoder = FormAssistant.documentEncoder; + encoder.setRange(range); + return encoder.encodeToString().length; +} + +function getContentEditableSelectionLength(element, selection) { + let encoder = FormAssistant.documentEncoder; + encoder.setRange(selection.getRangeAt(0)); + return encoder.encodeToString().length; +} + +function setSelectionRange(element, start, end) { + let isTextField = isPlainTextField(element); + + // Check the parameters + + if (!isTextField && !isContentEditable(element)) { + // Skip HTMLOptionElement and HTMLSelectElement elements, as they don't + // support the operation of setSelectionRange + return false; + } + + let text = isTextField ? element.value : getContentEditableText(element); + let length = text.length; + if (start < 0) { + start = 0; + } + if (end > length) { + end = length; + } + if (start > end) { + start = end; + } + + if (isTextField) { + // Set the selection range of <input> and <textarea> elements + element.setSelectionRange(start, end, "forward"); + return true; + } else { + // set the selection range of contenteditable elements + let win = element.ownerDocument.defaultView; + let sel = win.getSelection(); + + // Move the caret to the start position + sel.collapse(element, 0); + for (let i = 0; i < start; i++) { + sel.modify("move", "forward", "character"); + } + + // Avoid entering infinite loop in case we cannot change the selection + // range. See bug https://bugzilla.mozilla.org/show_bug.cgi?id=978918 + let oldStart = getContentEditableSelectionStart(element, sel); + let counter = 0; + while (oldStart < start) { + sel.modify("move", "forward", "character"); + let newStart = getContentEditableSelectionStart(element, sel); + if (oldStart == newStart) { + counter++; + if (counter > MAX_BLOCKED_COUNT) { + return false; + } + } else { + counter = 0; + oldStart = newStart; + } + } + + // Extend the selection to the end position + for (let i = start; i < end; i++) { + sel.modify("extend", "forward", "character"); + } + + // Avoid entering infinite loop in case we cannot change the selection + // range. See bug https://bugzilla.mozilla.org/show_bug.cgi?id=978918 + counter = 0; + let selectionLength = end - start; + let oldSelectionLength = getContentEditableSelectionLength(element, sel); + while (oldSelectionLength < selectionLength) { + sel.modify("extend", "forward", "character"); + let newSelectionLength = getContentEditableSelectionLength(element, sel); + if (oldSelectionLength == newSelectionLength ) { + counter++; + if (counter > MAX_BLOCKED_COUNT) { + return false; + } + } else { + counter = 0; + oldSelectionLength = newSelectionLength; + } + } + return true; + } +} + +/** + * Scroll the given element into view. + * + * Calls scrollSelectionIntoView for contentEditable elements. + */ +function scrollSelectionOrElementIntoView(element) { + let editor = getPlaintextEditor(element); + if (editor) { + editor.selectionController.scrollSelectionIntoView( + Ci.nsISelectionController.SELECTION_NORMAL, + Ci.nsISelectionController.SELECTION_FOCUS_REGION, + Ci.nsISelectionController.SCROLL_SYNCHRONOUS); + } else { + element.scrollIntoView(false); + } +} + +// Get nsIPlaintextEditor object from an input field +function getPlaintextEditor(element) { + let editor = null; + // Get nsIEditor + if (isPlainTextField(element)) { + // Get from the <input> and <textarea> elements + editor = element.QueryInterface(Ci.nsIDOMNSEditableElement).editor; + } else if (isContentEditable(element)) { + // Get from content editable element + let win = element.ownerDocument.defaultView; + let editingSession = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIEditingSession); + if (editingSession) { + editor = editingSession.getEditorForWindow(win); + } + } + if (editor) { + editor.QueryInterface(Ci.nsIPlaintextEditor); + } + return editor; +} + +function replaceSurroundingText(element, text, offset, length) { + let editor = FormAssistant.editor; + if (!editor) { + return false; + } + + // Check the parameters. + if (length < 0) { + length = 0; + } + + // Change selection range before replacing. For content editable element, + // searching the node for setting selection range is not needed when the + // selection is collapsed within a text node. + let fastPathHit = false; + if (!isPlainTextField(element)) { + let sel = element.ownerDocument.defaultView.getSelection(); + let node = sel.anchorNode; + if (sel.isCollapsed && node && node.nodeType == 3 /* TEXT_NODE */) { + let start = sel.anchorOffset + offset; + let end = start + length; + // Fallback to setSelectionRange() if the replacement span multiple nodes. + if (start >= 0 && end <= node.textContent.length) { + fastPathHit = true; + sel.collapse(node, start); + sel.extend(node, end); + } + } + } + if (!fastPathHit) { + let range = getSelectionRange(element); + let start = range[0] + offset; + if (start < 0) { + start = 0; + } + let end = start + length; + if (start != range[0] || end != range[1]) { + if (!setSelectionRange(element, start, end)) { + return false; + } + } + } + + if (length) { + // Delete the selected text. + editor.deleteSelection(Ci.nsIEditor.ePrevious, Ci.nsIEditor.eStrip); + } + + if (text) { + // We don't use CR but LF + // see https://bugzilla.mozilla.org/show_bug.cgi?id=902847 + text = text.replace(/\r/g, '\n'); + // Insert the text to be replaced with. + editor.insertText(text); + } + return true; +} + +var CompositionManager = { + _isStarted: false, + _tip: null, + _KeyboardEventForWin: null, + _clauseAttrMap: { + 'raw-input': + Ci.nsITextInputProcessor.ATTR_RAW_CLAUSE, + 'selected-raw-text': + Ci.nsITextInputProcessor.ATTR_SELECTED_RAW_CLAUSE, + 'converted-text': + Ci.nsITextInputProcessor.ATTR_CONVERTED_CLAUSE, + 'selected-converted-text': + Ci.nsITextInputProcessor.ATTR_SELECTED_CLAUSE + }, + + setComposition: function cm_setComposition(element, text, cursor, clauses, dict) { + // Check parameters. + if (!element) { + return; + } + let len = text.length; + if (cursor > len) { + cursor = len; + } + let clauseLens = []; + let clauseAttrs = []; + if (clauses) { + let remainingLength = len; + for (let i = 0; i < clauses.length; i++) { + if (clauses[i]) { + let clauseLength = clauses[i].length || 0; + // Make sure the total clauses length is not bigger than that of the + // composition string. + if (clauseLength > remainingLength) { + clauseLength = remainingLength; + } + remainingLength -= clauseLength; + clauseLens.push(clauseLength); + clauseAttrs.push(this._clauseAttrMap[clauses[i].selectionType] || + Ci.nsITextInputProcessor.ATTR_RAW_CLAUSE); + } + } + // If the total clauses length is less than that of the composition + // string, extend the last clause to the end of the composition string. + if (remainingLength > 0) { + clauseLens[clauseLens.length - 1] += remainingLength; + } + } else { + clauseLens.push(len); + clauseAttrs.push(Ci.nsITextInputProcessor.ATTR_RAW_CLAUSE); + } + + let win = element.ownerDocument.defaultView; + let tip = WindowMap.getTextInputProcessor(win); + if (!tip) { + return; + } + // Update the composing text. + tip.setPendingCompositionString(text); + for (var i = 0; i < clauseLens.length; i++) { + if (!clauseLens[i]) { + continue; + } + tip.appendClauseToPendingComposition(clauseLens[i], clauseAttrs[i]); + } + if (cursor >= 0) { + tip.setCaretInPendingComposition(cursor); + } + + if (!dict) { + this._isStarted = tip.flushPendingComposition(); + } else { + let keyboardEvent = new win.KeyboardEvent("", dict); + let flags = dict.flags; + this._isStarted = tip.flushPendingComposition(keyboardEvent, flags); + } + + if (this._isStarted) { + this._tip = tip; + this._KeyboardEventForWin = win.KeyboardEvent; + } + }, + + endComposition: function cm_endComposition(text, dict) { + if (!this._isStarted) { + return; + } + let tip = this._tip; + if (!tip) { + return; + } + + text = text || ""; + if (!dict) { + tip.commitCompositionWith(text); + } else { + let keyboardEvent = new this._KeyboardEventForWin("", dict); + let flags = dict.flags; + tip.commitCompositionWith(text, keyboardEvent, flags); + } + + this._isStarted = false; + this._tip = null; + this._KeyboardEventForWin = null; + }, + + // Composition ends due to external actions. + onCompositionEnd: function cm_onCompositionEnd() { + if (!this._isStarted) { + return; + } + + this._isStarted = false; + this._tip = null; + this._KeyboardEventForWin = null; + } +}; |