/* -*- 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; } };