Fri, 16 Jan 2015 18:13:44 +0100
Integrate suggestion from review to improve consistency with existing code.
michael@0 | 1 | /* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / |
michael@0 | 2 | /* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ |
michael@0 | 3 | /* This Source Code Form is subject to the terms of the Mozilla Public |
michael@0 | 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this file, |
michael@0 | 5 | * You can obtain one at http://mozilla.org/MPL/2.0/. */ |
michael@0 | 6 | |
michael@0 | 7 | "use strict"; |
michael@0 | 8 | |
michael@0 | 9 | dump("###################################### forms.js loaded\n"); |
michael@0 | 10 | |
michael@0 | 11 | let Ci = Components.interfaces; |
michael@0 | 12 | let Cc = Components.classes; |
michael@0 | 13 | let Cu = Components.utils; |
michael@0 | 14 | |
michael@0 | 15 | Cu.import("resource://gre/modules/Services.jsm"); |
michael@0 | 16 | Cu.import('resource://gre/modules/XPCOMUtils.jsm'); |
michael@0 | 17 | XPCOMUtils.defineLazyServiceGetter(Services, "fm", |
michael@0 | 18 | "@mozilla.org/focus-manager;1", |
michael@0 | 19 | "nsIFocusManager"); |
michael@0 | 20 | |
michael@0 | 21 | XPCOMUtils.defineLazyGetter(this, "domWindowUtils", function () { |
michael@0 | 22 | return content.QueryInterface(Ci.nsIInterfaceRequestor) |
michael@0 | 23 | .getInterface(Ci.nsIDOMWindowUtils); |
michael@0 | 24 | }); |
michael@0 | 25 | |
michael@0 | 26 | const RESIZE_SCROLL_DELAY = 20; |
michael@0 | 27 | // In content editable node, when there are hidden elements such as <br>, it |
michael@0 | 28 | // may need more than one (usually less than 3 times) move/extend operations |
michael@0 | 29 | // to change the selection range. If we cannot change the selection range |
michael@0 | 30 | // with more than 20 opertations, we are likely being blocked and cannot change |
michael@0 | 31 | // the selection range any more. |
michael@0 | 32 | const MAX_BLOCKED_COUNT = 20; |
michael@0 | 33 | |
michael@0 | 34 | let HTMLDocument = Ci.nsIDOMHTMLDocument; |
michael@0 | 35 | let HTMLHtmlElement = Ci.nsIDOMHTMLHtmlElement; |
michael@0 | 36 | let HTMLBodyElement = Ci.nsIDOMHTMLBodyElement; |
michael@0 | 37 | let HTMLIFrameElement = Ci.nsIDOMHTMLIFrameElement; |
michael@0 | 38 | let HTMLInputElement = Ci.nsIDOMHTMLInputElement; |
michael@0 | 39 | let HTMLTextAreaElement = Ci.nsIDOMHTMLTextAreaElement; |
michael@0 | 40 | let HTMLSelectElement = Ci.nsIDOMHTMLSelectElement; |
michael@0 | 41 | let HTMLOptGroupElement = Ci.nsIDOMHTMLOptGroupElement; |
michael@0 | 42 | let HTMLOptionElement = Ci.nsIDOMHTMLOptionElement; |
michael@0 | 43 | |
michael@0 | 44 | let FormVisibility = { |
michael@0 | 45 | /** |
michael@0 | 46 | * Searches upwards in the DOM for an element that has been scrolled. |
michael@0 | 47 | * |
michael@0 | 48 | * @param {HTMLElement} node element to start search at. |
michael@0 | 49 | * @return {Window|HTMLElement|Null} null when none are found window/element otherwise. |
michael@0 | 50 | */ |
michael@0 | 51 | findScrolled: function fv_findScrolled(node) { |
michael@0 | 52 | let win = node.ownerDocument.defaultView; |
michael@0 | 53 | |
michael@0 | 54 | while (!(node instanceof HTMLBodyElement)) { |
michael@0 | 55 | |
michael@0 | 56 | // We can skip elements that have not been scrolled. |
michael@0 | 57 | // We only care about top now remember to add the scrollLeft |
michael@0 | 58 | // check if we decide to care about the X axis. |
michael@0 | 59 | if (node.scrollTop !== 0) { |
michael@0 | 60 | // the element has been scrolled so we may need to adjust |
michael@0 | 61 | // where we think the root element is located. |
michael@0 | 62 | // |
michael@0 | 63 | // Otherwise it may seem visible but be scrolled out of the viewport |
michael@0 | 64 | // inside this scrollable node. |
michael@0 | 65 | return node; |
michael@0 | 66 | } else { |
michael@0 | 67 | // this node does not effect where we think |
michael@0 | 68 | // the node is even if it is scrollable it has not hidden |
michael@0 | 69 | // the element we are looking for. |
michael@0 | 70 | node = node.parentNode; |
michael@0 | 71 | continue; |
michael@0 | 72 | } |
michael@0 | 73 | } |
michael@0 | 74 | |
michael@0 | 75 | // we also care about the window this is the more |
michael@0 | 76 | // common case where the content is larger then |
michael@0 | 77 | // the viewport/screen. |
michael@0 | 78 | if (win.scrollMaxX || win.scrollMaxY) { |
michael@0 | 79 | return win; |
michael@0 | 80 | } |
michael@0 | 81 | |
michael@0 | 82 | return null; |
michael@0 | 83 | }, |
michael@0 | 84 | |
michael@0 | 85 | /** |
michael@0 | 86 | * Checks if "top and "bottom" points of the position is visible. |
michael@0 | 87 | * |
michael@0 | 88 | * @param {Number} top position. |
michael@0 | 89 | * @param {Number} height of the element. |
michael@0 | 90 | * @param {Number} maxHeight of the window. |
michael@0 | 91 | * @return {Boolean} true when visible. |
michael@0 | 92 | */ |
michael@0 | 93 | yAxisVisible: function fv_yAxisVisible(top, height, maxHeight) { |
michael@0 | 94 | return (top > 0 && (top + height) < maxHeight); |
michael@0 | 95 | }, |
michael@0 | 96 | |
michael@0 | 97 | /** |
michael@0 | 98 | * Searches up through the dom for scrollable elements |
michael@0 | 99 | * which are not currently visible (relative to the viewport). |
michael@0 | 100 | * |
michael@0 | 101 | * @param {HTMLElement} element to start search at. |
michael@0 | 102 | * @param {Object} pos .top, .height and .width of element. |
michael@0 | 103 | */ |
michael@0 | 104 | scrollablesVisible: function fv_scrollablesVisible(element, pos) { |
michael@0 | 105 | while ((element = this.findScrolled(element))) { |
michael@0 | 106 | if (element.window && element.self === element) |
michael@0 | 107 | break; |
michael@0 | 108 | |
michael@0 | 109 | // remember getBoundingClientRect does not care |
michael@0 | 110 | // about scrolling only where the element starts |
michael@0 | 111 | // in the document. |
michael@0 | 112 | let offset = element.getBoundingClientRect(); |
michael@0 | 113 | |
michael@0 | 114 | // the top of both the scrollable area and |
michael@0 | 115 | // the form element itself are in the same document. |
michael@0 | 116 | // We adjust the "top" so if the elements coordinates |
michael@0 | 117 | // are relative to the viewport in the current document. |
michael@0 | 118 | let adjustedTop = pos.top - offset.top; |
michael@0 | 119 | |
michael@0 | 120 | let visible = this.yAxisVisible( |
michael@0 | 121 | adjustedTop, |
michael@0 | 122 | pos.height, |
michael@0 | 123 | pos.width |
michael@0 | 124 | ); |
michael@0 | 125 | |
michael@0 | 126 | if (!visible) |
michael@0 | 127 | return false; |
michael@0 | 128 | |
michael@0 | 129 | element = element.parentNode; |
michael@0 | 130 | } |
michael@0 | 131 | |
michael@0 | 132 | return true; |
michael@0 | 133 | }, |
michael@0 | 134 | |
michael@0 | 135 | /** |
michael@0 | 136 | * Verifies the element is visible in the viewport. |
michael@0 | 137 | * Handles scrollable areas, frames and scrollable viewport(s) (windows). |
michael@0 | 138 | * |
michael@0 | 139 | * @param {HTMLElement} element to verify. |
michael@0 | 140 | * @return {Boolean} true when visible. |
michael@0 | 141 | */ |
michael@0 | 142 | isVisible: function fv_isVisible(element) { |
michael@0 | 143 | // scrollable frames can be ignored we just care about iframes... |
michael@0 | 144 | let rect = element.getBoundingClientRect(); |
michael@0 | 145 | let parent = element.ownerDocument.defaultView; |
michael@0 | 146 | |
michael@0 | 147 | // used to calculate the inner position of frames / scrollables. |
michael@0 | 148 | // The intent was to use this information to scroll either up or down. |
michael@0 | 149 | // scrollIntoView(true) will _break_ some web content so we can't do |
michael@0 | 150 | // this today. If we want that functionality we need to manually scroll |
michael@0 | 151 | // the individual elements. |
michael@0 | 152 | let pos = { |
michael@0 | 153 | top: rect.top, |
michael@0 | 154 | height: rect.height, |
michael@0 | 155 | width: rect.width |
michael@0 | 156 | }; |
michael@0 | 157 | |
michael@0 | 158 | let visible = true; |
michael@0 | 159 | |
michael@0 | 160 | do { |
michael@0 | 161 | let frame = parent.frameElement; |
michael@0 | 162 | visible = visible && |
michael@0 | 163 | this.yAxisVisible(pos.top, pos.height, parent.innerHeight) && |
michael@0 | 164 | this.scrollablesVisible(element, pos); |
michael@0 | 165 | |
michael@0 | 166 | // nothing we can do about this now... |
michael@0 | 167 | // In the future we can use this information to scroll |
michael@0 | 168 | // only the elements we need to at this point as we should |
michael@0 | 169 | // have all the details we need to figure out how to scroll. |
michael@0 | 170 | if (!visible) |
michael@0 | 171 | return false; |
michael@0 | 172 | |
michael@0 | 173 | if (frame) { |
michael@0 | 174 | let frameRect = frame.getBoundingClientRect(); |
michael@0 | 175 | |
michael@0 | 176 | pos.top += frameRect.top + frame.clientTop; |
michael@0 | 177 | } |
michael@0 | 178 | } while ( |
michael@0 | 179 | (parent !== parent.parent) && |
michael@0 | 180 | (parent = parent.parent) |
michael@0 | 181 | ); |
michael@0 | 182 | |
michael@0 | 183 | return visible; |
michael@0 | 184 | } |
michael@0 | 185 | }; |
michael@0 | 186 | |
michael@0 | 187 | let FormAssistant = { |
michael@0 | 188 | init: function fa_init() { |
michael@0 | 189 | addEventListener("focus", this, true, false); |
michael@0 | 190 | addEventListener("blur", this, true, false); |
michael@0 | 191 | addEventListener("resize", this, true, false); |
michael@0 | 192 | addEventListener("submit", this, true, false); |
michael@0 | 193 | addEventListener("pagehide", this, true, false); |
michael@0 | 194 | addEventListener("beforeunload", this, true, false); |
michael@0 | 195 | addEventListener("input", this, true, false); |
michael@0 | 196 | addEventListener("keydown", this, true, false); |
michael@0 | 197 | addEventListener("keyup", this, true, false); |
michael@0 | 198 | addMessageListener("Forms:Select:Choice", this); |
michael@0 | 199 | addMessageListener("Forms:Input:Value", this); |
michael@0 | 200 | addMessageListener("Forms:Select:Blur", this); |
michael@0 | 201 | addMessageListener("Forms:SetSelectionRange", this); |
michael@0 | 202 | addMessageListener("Forms:ReplaceSurroundingText", this); |
michael@0 | 203 | addMessageListener("Forms:GetText", this); |
michael@0 | 204 | addMessageListener("Forms:Input:SendKey", this); |
michael@0 | 205 | addMessageListener("Forms:GetContext", this); |
michael@0 | 206 | addMessageListener("Forms:SetComposition", this); |
michael@0 | 207 | addMessageListener("Forms:EndComposition", this); |
michael@0 | 208 | }, |
michael@0 | 209 | |
michael@0 | 210 | ignoredInputTypes: new Set([ |
michael@0 | 211 | 'button', 'file', 'checkbox', 'radio', 'reset', 'submit', 'image', |
michael@0 | 212 | 'range' |
michael@0 | 213 | ]), |
michael@0 | 214 | |
michael@0 | 215 | isKeyboardOpened: false, |
michael@0 | 216 | selectionStart: -1, |
michael@0 | 217 | selectionEnd: -1, |
michael@0 | 218 | textBeforeCursor: "", |
michael@0 | 219 | textAfterCursor: "", |
michael@0 | 220 | scrollIntoViewTimeout: null, |
michael@0 | 221 | _focusedElement: null, |
michael@0 | 222 | _focusCounter: 0, // up one for every time we focus a new element |
michael@0 | 223 | _observer: null, |
michael@0 | 224 | _documentEncoder: null, |
michael@0 | 225 | _editor: null, |
michael@0 | 226 | _editing: false, |
michael@0 | 227 | |
michael@0 | 228 | get focusedElement() { |
michael@0 | 229 | if (this._focusedElement && Cu.isDeadWrapper(this._focusedElement)) |
michael@0 | 230 | this._focusedElement = null; |
michael@0 | 231 | |
michael@0 | 232 | return this._focusedElement; |
michael@0 | 233 | }, |
michael@0 | 234 | |
michael@0 | 235 | set focusedElement(val) { |
michael@0 | 236 | this._focusCounter++; |
michael@0 | 237 | this._focusedElement = val; |
michael@0 | 238 | }, |
michael@0 | 239 | |
michael@0 | 240 | setFocusedElement: function fa_setFocusedElement(element) { |
michael@0 | 241 | let self = this; |
michael@0 | 242 | |
michael@0 | 243 | if (element === this.focusedElement) |
michael@0 | 244 | return; |
michael@0 | 245 | |
michael@0 | 246 | if (this.focusedElement) { |
michael@0 | 247 | this.focusedElement.removeEventListener('mousedown', this); |
michael@0 | 248 | this.focusedElement.removeEventListener('mouseup', this); |
michael@0 | 249 | this.focusedElement.removeEventListener('compositionend', this); |
michael@0 | 250 | if (this._observer) { |
michael@0 | 251 | this._observer.disconnect(); |
michael@0 | 252 | this._observer = null; |
michael@0 | 253 | } |
michael@0 | 254 | if (!element) { |
michael@0 | 255 | this.focusedElement.blur(); |
michael@0 | 256 | } |
michael@0 | 257 | } |
michael@0 | 258 | |
michael@0 | 259 | this._documentEncoder = null; |
michael@0 | 260 | if (this._editor) { |
michael@0 | 261 | // When the nsIFrame of the input element is reconstructed by |
michael@0 | 262 | // CSS restyling, the editor observers are removed. Catch |
michael@0 | 263 | // [nsIEditor.removeEditorObserver] failure exception if that |
michael@0 | 264 | // happens. |
michael@0 | 265 | try { |
michael@0 | 266 | this._editor.removeEditorObserver(this); |
michael@0 | 267 | } catch (e) {} |
michael@0 | 268 | this._editor = null; |
michael@0 | 269 | } |
michael@0 | 270 | |
michael@0 | 271 | if (element) { |
michael@0 | 272 | element.addEventListener('mousedown', this); |
michael@0 | 273 | element.addEventListener('mouseup', this); |
michael@0 | 274 | element.addEventListener('compositionend', this); |
michael@0 | 275 | if (isContentEditable(element)) { |
michael@0 | 276 | this._documentEncoder = getDocumentEncoder(element); |
michael@0 | 277 | } |
michael@0 | 278 | this._editor = getPlaintextEditor(element); |
michael@0 | 279 | if (this._editor) { |
michael@0 | 280 | // Add a nsIEditorObserver to monitor the text content of the focused |
michael@0 | 281 | // element. |
michael@0 | 282 | this._editor.addEditorObserver(this); |
michael@0 | 283 | } |
michael@0 | 284 | |
michael@0 | 285 | // If our focusedElement is removed from DOM we want to handle it properly |
michael@0 | 286 | let MutationObserver = element.ownerDocument.defaultView.MutationObserver; |
michael@0 | 287 | this._observer = new MutationObserver(function(mutations) { |
michael@0 | 288 | var del = [].some.call(mutations, function(m) { |
michael@0 | 289 | return [].some.call(m.removedNodes, function(n) { |
michael@0 | 290 | return n.contains(element); |
michael@0 | 291 | }); |
michael@0 | 292 | }); |
michael@0 | 293 | if (del && element === self.focusedElement) { |
michael@0 | 294 | // item was deleted, fake a blur so all state gets set correctly |
michael@0 | 295 | self.handleEvent({ target: element, type: "blur" }); |
michael@0 | 296 | } |
michael@0 | 297 | }); |
michael@0 | 298 | |
michael@0 | 299 | this._observer.observe(element.ownerDocument.body, { |
michael@0 | 300 | childList: true, |
michael@0 | 301 | subtree: true |
michael@0 | 302 | }); |
michael@0 | 303 | } |
michael@0 | 304 | |
michael@0 | 305 | this.focusedElement = element; |
michael@0 | 306 | }, |
michael@0 | 307 | |
michael@0 | 308 | get documentEncoder() { |
michael@0 | 309 | return this._documentEncoder; |
michael@0 | 310 | }, |
michael@0 | 311 | |
michael@0 | 312 | // Get the nsIPlaintextEditor object of current input field. |
michael@0 | 313 | get editor() { |
michael@0 | 314 | return this._editor; |
michael@0 | 315 | }, |
michael@0 | 316 | |
michael@0 | 317 | // Implements nsIEditorObserver get notification when the text content of |
michael@0 | 318 | // current input field has changed. |
michael@0 | 319 | EditAction: function fa_editAction() { |
michael@0 | 320 | if (this._editing) { |
michael@0 | 321 | return; |
michael@0 | 322 | } |
michael@0 | 323 | this.sendKeyboardState(this.focusedElement); |
michael@0 | 324 | }, |
michael@0 | 325 | |
michael@0 | 326 | handleEvent: function fa_handleEvent(evt) { |
michael@0 | 327 | let target = evt.target; |
michael@0 | 328 | |
michael@0 | 329 | let range = null; |
michael@0 | 330 | switch (evt.type) { |
michael@0 | 331 | case "focus": |
michael@0 | 332 | if (!target) { |
michael@0 | 333 | break; |
michael@0 | 334 | } |
michael@0 | 335 | |
michael@0 | 336 | // Focusing on Window, Document or iFrame should focus body |
michael@0 | 337 | if (target instanceof HTMLHtmlElement) { |
michael@0 | 338 | target = target.document.body; |
michael@0 | 339 | } else if (target instanceof HTMLDocument) { |
michael@0 | 340 | target = target.body; |
michael@0 | 341 | } else if (target instanceof HTMLIFrameElement) { |
michael@0 | 342 | target = target.contentDocument ? target.contentDocument.body |
michael@0 | 343 | : null; |
michael@0 | 344 | } |
michael@0 | 345 | |
michael@0 | 346 | if (!target) { |
michael@0 | 347 | break; |
michael@0 | 348 | } |
michael@0 | 349 | |
michael@0 | 350 | if (isContentEditable(target)) { |
michael@0 | 351 | this.showKeyboard(this.getTopLevelEditable(target)); |
michael@0 | 352 | this.updateSelection(); |
michael@0 | 353 | break; |
michael@0 | 354 | } |
michael@0 | 355 | |
michael@0 | 356 | if (this.isFocusableElement(target)) { |
michael@0 | 357 | this.showKeyboard(target); |
michael@0 | 358 | this.updateSelection(); |
michael@0 | 359 | } |
michael@0 | 360 | break; |
michael@0 | 361 | |
michael@0 | 362 | case "pagehide": |
michael@0 | 363 | case "beforeunload": |
michael@0 | 364 | // We are only interested to the pagehide and beforeunload events from |
michael@0 | 365 | // the root document. |
michael@0 | 366 | if (target && target != content.document) { |
michael@0 | 367 | break; |
michael@0 | 368 | } |
michael@0 | 369 | // fall through |
michael@0 | 370 | case "blur": |
michael@0 | 371 | case "submit": |
michael@0 | 372 | if (this.focusedElement) { |
michael@0 | 373 | this.hideKeyboard(); |
michael@0 | 374 | this.selectionStart = -1; |
michael@0 | 375 | this.selectionEnd = -1; |
michael@0 | 376 | } |
michael@0 | 377 | break; |
michael@0 | 378 | |
michael@0 | 379 | case 'mousedown': |
michael@0 | 380 | if (!this.focusedElement) { |
michael@0 | 381 | break; |
michael@0 | 382 | } |
michael@0 | 383 | |
michael@0 | 384 | // We only listen for this event on the currently focused element. |
michael@0 | 385 | // When the mouse goes down, note the cursor/selection position |
michael@0 | 386 | this.updateSelection(); |
michael@0 | 387 | break; |
michael@0 | 388 | |
michael@0 | 389 | case 'mouseup': |
michael@0 | 390 | if (!this.focusedElement) { |
michael@0 | 391 | break; |
michael@0 | 392 | } |
michael@0 | 393 | |
michael@0 | 394 | // We only listen for this event on the currently focused element. |
michael@0 | 395 | // When the mouse goes up, see if the cursor has moved (or the |
michael@0 | 396 | // selection changed) since the mouse went down. If it has, we |
michael@0 | 397 | // need to tell the keyboard about it |
michael@0 | 398 | range = getSelectionRange(this.focusedElement); |
michael@0 | 399 | if (range[0] !== this.selectionStart || |
michael@0 | 400 | range[1] !== this.selectionEnd) { |
michael@0 | 401 | this.updateSelection(); |
michael@0 | 402 | } |
michael@0 | 403 | break; |
michael@0 | 404 | |
michael@0 | 405 | case "resize": |
michael@0 | 406 | if (!this.isKeyboardOpened) |
michael@0 | 407 | return; |
michael@0 | 408 | |
michael@0 | 409 | if (this.scrollIntoViewTimeout) { |
michael@0 | 410 | content.clearTimeout(this.scrollIntoViewTimeout); |
michael@0 | 411 | this.scrollIntoViewTimeout = null; |
michael@0 | 412 | } |
michael@0 | 413 | |
michael@0 | 414 | // We may receive multiple resize events in quick succession, so wait |
michael@0 | 415 | // a bit before scrolling the input element into view. |
michael@0 | 416 | if (this.focusedElement) { |
michael@0 | 417 | this.scrollIntoViewTimeout = content.setTimeout(function () { |
michael@0 | 418 | this.scrollIntoViewTimeout = null; |
michael@0 | 419 | if (this.focusedElement && !FormVisibility.isVisible(this.focusedElement)) { |
michael@0 | 420 | scrollSelectionOrElementIntoView(this.focusedElement); |
michael@0 | 421 | } |
michael@0 | 422 | }.bind(this), RESIZE_SCROLL_DELAY); |
michael@0 | 423 | } |
michael@0 | 424 | break; |
michael@0 | 425 | |
michael@0 | 426 | case "input": |
michael@0 | 427 | if (this.focusedElement) { |
michael@0 | 428 | // When the text content changes, notify the keyboard |
michael@0 | 429 | this.updateSelection(); |
michael@0 | 430 | } |
michael@0 | 431 | break; |
michael@0 | 432 | |
michael@0 | 433 | case "keydown": |
michael@0 | 434 | if (!this.focusedElement) { |
michael@0 | 435 | break; |
michael@0 | 436 | } |
michael@0 | 437 | |
michael@0 | 438 | CompositionManager.endComposition(''); |
michael@0 | 439 | |
michael@0 | 440 | // We use 'setTimeout' to wait until the input element accomplishes the |
michael@0 | 441 | // change in selection range. |
michael@0 | 442 | content.setTimeout(function() { |
michael@0 | 443 | this.updateSelection(); |
michael@0 | 444 | }.bind(this), 0); |
michael@0 | 445 | break; |
michael@0 | 446 | |
michael@0 | 447 | case "keyup": |
michael@0 | 448 | if (!this.focusedElement) { |
michael@0 | 449 | break; |
michael@0 | 450 | } |
michael@0 | 451 | |
michael@0 | 452 | CompositionManager.endComposition(''); |
michael@0 | 453 | |
michael@0 | 454 | break; |
michael@0 | 455 | |
michael@0 | 456 | case "compositionend": |
michael@0 | 457 | if (!this.focusedElement) { |
michael@0 | 458 | break; |
michael@0 | 459 | } |
michael@0 | 460 | |
michael@0 | 461 | CompositionManager.onCompositionEnd(); |
michael@0 | 462 | break; |
michael@0 | 463 | } |
michael@0 | 464 | }, |
michael@0 | 465 | |
michael@0 | 466 | receiveMessage: function fa_receiveMessage(msg) { |
michael@0 | 467 | let target = this.focusedElement; |
michael@0 | 468 | let json = msg.json; |
michael@0 | 469 | |
michael@0 | 470 | // To not break mozKeyboard contextId is optional |
michael@0 | 471 | if ('contextId' in json && |
michael@0 | 472 | json.contextId !== this._focusCounter && |
michael@0 | 473 | json.requestId) { |
michael@0 | 474 | // Ignore messages that are meant for a previously focused element |
michael@0 | 475 | sendAsyncMessage("Forms:SequenceError", { |
michael@0 | 476 | requestId: json.requestId, |
michael@0 | 477 | error: "Expected contextId " + this._focusCounter + |
michael@0 | 478 | " but was " + json.contextId |
michael@0 | 479 | }); |
michael@0 | 480 | return; |
michael@0 | 481 | } |
michael@0 | 482 | |
michael@0 | 483 | if (!target) { |
michael@0 | 484 | switch (msg.name) { |
michael@0 | 485 | case "Forms:GetText": |
michael@0 | 486 | sendAsyncMessage("Forms:GetText:Result:Error", { |
michael@0 | 487 | requestId: json.requestId, |
michael@0 | 488 | error: "No focused element" |
michael@0 | 489 | }); |
michael@0 | 490 | break; |
michael@0 | 491 | } |
michael@0 | 492 | return; |
michael@0 | 493 | } |
michael@0 | 494 | |
michael@0 | 495 | this._editing = true; |
michael@0 | 496 | switch (msg.name) { |
michael@0 | 497 | case "Forms:Input:Value": { |
michael@0 | 498 | CompositionManager.endComposition(''); |
michael@0 | 499 | |
michael@0 | 500 | target.value = json.value; |
michael@0 | 501 | |
michael@0 | 502 | let event = target.ownerDocument.createEvent('HTMLEvents'); |
michael@0 | 503 | event.initEvent('input', true, false); |
michael@0 | 504 | target.dispatchEvent(event); |
michael@0 | 505 | break; |
michael@0 | 506 | } |
michael@0 | 507 | |
michael@0 | 508 | case "Forms:Input:SendKey": |
michael@0 | 509 | CompositionManager.endComposition(''); |
michael@0 | 510 | |
michael@0 | 511 | this._editing = true; |
michael@0 | 512 | let doKeypress = domWindowUtils.sendKeyEvent('keydown', json.keyCode, |
michael@0 | 513 | json.charCode, json.modifiers); |
michael@0 | 514 | if (doKeypress) { |
michael@0 | 515 | domWindowUtils.sendKeyEvent('keypress', json.keyCode, |
michael@0 | 516 | json.charCode, json.modifiers); |
michael@0 | 517 | } |
michael@0 | 518 | |
michael@0 | 519 | if(!json.repeat) { |
michael@0 | 520 | domWindowUtils.sendKeyEvent('keyup', json.keyCode, |
michael@0 | 521 | json.charCode, json.modifiers); |
michael@0 | 522 | } |
michael@0 | 523 | |
michael@0 | 524 | this._editing = false; |
michael@0 | 525 | |
michael@0 | 526 | if (json.requestId && doKeypress) { |
michael@0 | 527 | sendAsyncMessage("Forms:SendKey:Result:OK", { |
michael@0 | 528 | requestId: json.requestId |
michael@0 | 529 | }); |
michael@0 | 530 | } |
michael@0 | 531 | else if (json.requestId && !doKeypress) { |
michael@0 | 532 | sendAsyncMessage("Forms:SendKey:Result:Error", { |
michael@0 | 533 | requestId: json.requestId, |
michael@0 | 534 | error: "Keydown event got canceled" |
michael@0 | 535 | }); |
michael@0 | 536 | } |
michael@0 | 537 | break; |
michael@0 | 538 | |
michael@0 | 539 | case "Forms:Select:Choice": |
michael@0 | 540 | let options = target.options; |
michael@0 | 541 | let valueChanged = false; |
michael@0 | 542 | if ("index" in json) { |
michael@0 | 543 | if (options.selectedIndex != json.index) { |
michael@0 | 544 | options.selectedIndex = json.index; |
michael@0 | 545 | valueChanged = true; |
michael@0 | 546 | } |
michael@0 | 547 | } else if ("indexes" in json) { |
michael@0 | 548 | for (let i = 0; i < options.length; i++) { |
michael@0 | 549 | let newValue = (json.indexes.indexOf(i) != -1); |
michael@0 | 550 | if (options.item(i).selected != newValue) { |
michael@0 | 551 | options.item(i).selected = newValue; |
michael@0 | 552 | valueChanged = true; |
michael@0 | 553 | } |
michael@0 | 554 | } |
michael@0 | 555 | } |
michael@0 | 556 | |
michael@0 | 557 | // only fire onchange event if any selected option is changed |
michael@0 | 558 | if (valueChanged) { |
michael@0 | 559 | let event = target.ownerDocument.createEvent('HTMLEvents'); |
michael@0 | 560 | event.initEvent('change', true, true); |
michael@0 | 561 | target.dispatchEvent(event); |
michael@0 | 562 | } |
michael@0 | 563 | break; |
michael@0 | 564 | |
michael@0 | 565 | case "Forms:Select:Blur": { |
michael@0 | 566 | this.setFocusedElement(null); |
michael@0 | 567 | break; |
michael@0 | 568 | } |
michael@0 | 569 | |
michael@0 | 570 | case "Forms:SetSelectionRange": { |
michael@0 | 571 | CompositionManager.endComposition(''); |
michael@0 | 572 | |
michael@0 | 573 | let start = json.selectionStart; |
michael@0 | 574 | let end = json.selectionEnd; |
michael@0 | 575 | |
michael@0 | 576 | if (!setSelectionRange(target, start, end)) { |
michael@0 | 577 | if (json.requestId) { |
michael@0 | 578 | sendAsyncMessage("Forms:SetSelectionRange:Result:Error", { |
michael@0 | 579 | requestId: json.requestId, |
michael@0 | 580 | error: "failed" |
michael@0 | 581 | }); |
michael@0 | 582 | } |
michael@0 | 583 | break; |
michael@0 | 584 | } |
michael@0 | 585 | |
michael@0 | 586 | this.updateSelection(); |
michael@0 | 587 | |
michael@0 | 588 | if (json.requestId) { |
michael@0 | 589 | sendAsyncMessage("Forms:SetSelectionRange:Result:OK", { |
michael@0 | 590 | requestId: json.requestId, |
michael@0 | 591 | selectioninfo: this.getSelectionInfo() |
michael@0 | 592 | }); |
michael@0 | 593 | } |
michael@0 | 594 | break; |
michael@0 | 595 | } |
michael@0 | 596 | |
michael@0 | 597 | case "Forms:ReplaceSurroundingText": { |
michael@0 | 598 | CompositionManager.endComposition(''); |
michael@0 | 599 | |
michael@0 | 600 | let selectionRange = getSelectionRange(target); |
michael@0 | 601 | if (!replaceSurroundingText(target, |
michael@0 | 602 | json.text, |
michael@0 | 603 | selectionRange[0], |
michael@0 | 604 | selectionRange[1], |
michael@0 | 605 | json.offset, |
michael@0 | 606 | json.length)) { |
michael@0 | 607 | if (json.requestId) { |
michael@0 | 608 | sendAsyncMessage("Forms:ReplaceSurroundingText:Result:Error", { |
michael@0 | 609 | requestId: json.requestId, |
michael@0 | 610 | error: "failed" |
michael@0 | 611 | }); |
michael@0 | 612 | } |
michael@0 | 613 | break; |
michael@0 | 614 | } |
michael@0 | 615 | |
michael@0 | 616 | if (json.requestId) { |
michael@0 | 617 | sendAsyncMessage("Forms:ReplaceSurroundingText:Result:OK", { |
michael@0 | 618 | requestId: json.requestId, |
michael@0 | 619 | selectioninfo: this.getSelectionInfo() |
michael@0 | 620 | }); |
michael@0 | 621 | } |
michael@0 | 622 | break; |
michael@0 | 623 | } |
michael@0 | 624 | |
michael@0 | 625 | case "Forms:GetText": { |
michael@0 | 626 | let value = isContentEditable(target) ? getContentEditableText(target) |
michael@0 | 627 | : target.value; |
michael@0 | 628 | |
michael@0 | 629 | if (json.offset && json.length) { |
michael@0 | 630 | value = value.substr(json.offset, json.length); |
michael@0 | 631 | } |
michael@0 | 632 | else if (json.offset) { |
michael@0 | 633 | value = value.substr(json.offset); |
michael@0 | 634 | } |
michael@0 | 635 | |
michael@0 | 636 | sendAsyncMessage("Forms:GetText:Result:OK", { |
michael@0 | 637 | requestId: json.requestId, |
michael@0 | 638 | text: value |
michael@0 | 639 | }); |
michael@0 | 640 | break; |
michael@0 | 641 | } |
michael@0 | 642 | |
michael@0 | 643 | case "Forms:GetContext": { |
michael@0 | 644 | let obj = getJSON(target, this._focusCounter); |
michael@0 | 645 | sendAsyncMessage("Forms:GetContext:Result:OK", obj); |
michael@0 | 646 | break; |
michael@0 | 647 | } |
michael@0 | 648 | |
michael@0 | 649 | case "Forms:SetComposition": { |
michael@0 | 650 | CompositionManager.setComposition(target, json.text, json.cursor, |
michael@0 | 651 | json.clauses); |
michael@0 | 652 | sendAsyncMessage("Forms:SetComposition:Result:OK", { |
michael@0 | 653 | requestId: json.requestId, |
michael@0 | 654 | }); |
michael@0 | 655 | break; |
michael@0 | 656 | } |
michael@0 | 657 | |
michael@0 | 658 | case "Forms:EndComposition": { |
michael@0 | 659 | CompositionManager.endComposition(json.text); |
michael@0 | 660 | sendAsyncMessage("Forms:EndComposition:Result:OK", { |
michael@0 | 661 | requestId: json.requestId, |
michael@0 | 662 | }); |
michael@0 | 663 | break; |
michael@0 | 664 | } |
michael@0 | 665 | } |
michael@0 | 666 | this._editing = false; |
michael@0 | 667 | |
michael@0 | 668 | }, |
michael@0 | 669 | |
michael@0 | 670 | showKeyboard: function fa_showKeyboard(target) { |
michael@0 | 671 | if (this.focusedElement === target) |
michael@0 | 672 | return; |
michael@0 | 673 | |
michael@0 | 674 | if (target instanceof HTMLOptionElement) |
michael@0 | 675 | target = target.parentNode; |
michael@0 | 676 | |
michael@0 | 677 | this.setFocusedElement(target); |
michael@0 | 678 | |
michael@0 | 679 | let kbOpened = this.sendKeyboardState(target); |
michael@0 | 680 | if (this.isTextInputElement(target)) |
michael@0 | 681 | this.isKeyboardOpened = kbOpened; |
michael@0 | 682 | }, |
michael@0 | 683 | |
michael@0 | 684 | hideKeyboard: function fa_hideKeyboard() { |
michael@0 | 685 | sendAsyncMessage("Forms:Input", { "type": "blur" }); |
michael@0 | 686 | this.isKeyboardOpened = false; |
michael@0 | 687 | this.setFocusedElement(null); |
michael@0 | 688 | }, |
michael@0 | 689 | |
michael@0 | 690 | isFocusableElement: function fa_isFocusableElement(element) { |
michael@0 | 691 | if (element instanceof HTMLSelectElement || |
michael@0 | 692 | element instanceof HTMLTextAreaElement) |
michael@0 | 693 | return true; |
michael@0 | 694 | |
michael@0 | 695 | if (element instanceof HTMLOptionElement && |
michael@0 | 696 | element.parentNode instanceof HTMLSelectElement) |
michael@0 | 697 | return true; |
michael@0 | 698 | |
michael@0 | 699 | return (element instanceof HTMLInputElement && |
michael@0 | 700 | !this.ignoredInputTypes.has(element.type)); |
michael@0 | 701 | }, |
michael@0 | 702 | |
michael@0 | 703 | isTextInputElement: function fa_isTextInputElement(element) { |
michael@0 | 704 | return element instanceof HTMLInputElement || |
michael@0 | 705 | element instanceof HTMLTextAreaElement || |
michael@0 | 706 | isContentEditable(element); |
michael@0 | 707 | }, |
michael@0 | 708 | |
michael@0 | 709 | getTopLevelEditable: function fa_getTopLevelEditable(element) { |
michael@0 | 710 | function retrieveTopLevelEditable(element) { |
michael@0 | 711 | while (element && !isContentEditable(element)) |
michael@0 | 712 | element = element.parentNode; |
michael@0 | 713 | |
michael@0 | 714 | return element; |
michael@0 | 715 | } |
michael@0 | 716 | |
michael@0 | 717 | return retrieveTopLevelEditable(element) || element; |
michael@0 | 718 | }, |
michael@0 | 719 | |
michael@0 | 720 | sendKeyboardState: function(element) { |
michael@0 | 721 | // FIXME/bug 729623: work around apparent bug in the IME manager |
michael@0 | 722 | // in gecko. |
michael@0 | 723 | let readonly = element.getAttribute("readonly"); |
michael@0 | 724 | if (readonly) { |
michael@0 | 725 | return false; |
michael@0 | 726 | } |
michael@0 | 727 | |
michael@0 | 728 | sendAsyncMessage("Forms:Input", getJSON(element, this._focusCounter)); |
michael@0 | 729 | return true; |
michael@0 | 730 | }, |
michael@0 | 731 | |
michael@0 | 732 | getSelectionInfo: function fa_getSelectionInfo() { |
michael@0 | 733 | let element = this.focusedElement; |
michael@0 | 734 | let range = getSelectionRange(element); |
michael@0 | 735 | |
michael@0 | 736 | let text = isContentEditable(element) ? getContentEditableText(element) |
michael@0 | 737 | : element.value; |
michael@0 | 738 | |
michael@0 | 739 | let textAround = getTextAroundCursor(text, range); |
michael@0 | 740 | |
michael@0 | 741 | let changed = this.selectionStart !== range[0] || |
michael@0 | 742 | this.selectionEnd !== range[1] || |
michael@0 | 743 | this.textBeforeCursor !== textAround.before || |
michael@0 | 744 | this.textAfterCursor !== textAround.after; |
michael@0 | 745 | |
michael@0 | 746 | this.selectionStart = range[0]; |
michael@0 | 747 | this.selectionEnd = range[1]; |
michael@0 | 748 | this.textBeforeCursor = textAround.before; |
michael@0 | 749 | this.textAfterCursor = textAround.after; |
michael@0 | 750 | |
michael@0 | 751 | return { |
michael@0 | 752 | selectionStart: range[0], |
michael@0 | 753 | selectionEnd: range[1], |
michael@0 | 754 | textBeforeCursor: textAround.before, |
michael@0 | 755 | textAfterCursor: textAround.after, |
michael@0 | 756 | changed: changed |
michael@0 | 757 | }; |
michael@0 | 758 | }, |
michael@0 | 759 | |
michael@0 | 760 | // Notify when the selection range changes |
michael@0 | 761 | updateSelection: function fa_updateSelection() { |
michael@0 | 762 | if (!this.focusedElement) { |
michael@0 | 763 | return; |
michael@0 | 764 | } |
michael@0 | 765 | let selectionInfo = this.getSelectionInfo(); |
michael@0 | 766 | if (selectionInfo.changed) { |
michael@0 | 767 | sendAsyncMessage("Forms:SelectionChange", this.getSelectionInfo()); |
michael@0 | 768 | } |
michael@0 | 769 | } |
michael@0 | 770 | }; |
michael@0 | 771 | |
michael@0 | 772 | FormAssistant.init(); |
michael@0 | 773 | |
michael@0 | 774 | function isContentEditable(element) { |
michael@0 | 775 | if (!element) { |
michael@0 | 776 | return false; |
michael@0 | 777 | } |
michael@0 | 778 | |
michael@0 | 779 | if (element.isContentEditable || element.designMode == "on") |
michael@0 | 780 | return true; |
michael@0 | 781 | |
michael@0 | 782 | return element.ownerDocument && element.ownerDocument.designMode == "on"; |
michael@0 | 783 | } |
michael@0 | 784 | |
michael@0 | 785 | function isPlainTextField(element) { |
michael@0 | 786 | if (!element) { |
michael@0 | 787 | return false; |
michael@0 | 788 | } |
michael@0 | 789 | |
michael@0 | 790 | return element instanceof HTMLTextAreaElement || |
michael@0 | 791 | (element instanceof HTMLInputElement && |
michael@0 | 792 | element.mozIsTextField(false)); |
michael@0 | 793 | } |
michael@0 | 794 | |
michael@0 | 795 | function getJSON(element, focusCounter) { |
michael@0 | 796 | // <input type=number> has a nested anonymous <input type=text> element that |
michael@0 | 797 | // takes focus on behalf of the number control when someone tries to focus |
michael@0 | 798 | // the number control. If |element| is such an anonymous text control then we |
michael@0 | 799 | // need it's number control here in order to get the correct 'type' etc.: |
michael@0 | 800 | element = element.ownerNumberControl || element; |
michael@0 | 801 | |
michael@0 | 802 | let type = element.type || ""; |
michael@0 | 803 | let value = element.value || ""; |
michael@0 | 804 | let max = element.max || ""; |
michael@0 | 805 | let min = element.min || ""; |
michael@0 | 806 | |
michael@0 | 807 | // Treat contenteditble element as a special text area field |
michael@0 | 808 | if (isContentEditable(element)) { |
michael@0 | 809 | type = "textarea"; |
michael@0 | 810 | value = getContentEditableText(element); |
michael@0 | 811 | } |
michael@0 | 812 | |
michael@0 | 813 | // Until the input type=date/datetime/range have been implemented |
michael@0 | 814 | // let's return their real type even if the platform returns 'text' |
michael@0 | 815 | let attributeType = element.getAttribute("type") || ""; |
michael@0 | 816 | |
michael@0 | 817 | if (attributeType) { |
michael@0 | 818 | var typeLowerCase = attributeType.toLowerCase(); |
michael@0 | 819 | switch (typeLowerCase) { |
michael@0 | 820 | case "datetime": |
michael@0 | 821 | case "datetime-local": |
michael@0 | 822 | case "range": |
michael@0 | 823 | type = typeLowerCase; |
michael@0 | 824 | break; |
michael@0 | 825 | } |
michael@0 | 826 | } |
michael@0 | 827 | |
michael@0 | 828 | // Gecko has some support for @inputmode but behind a preference and |
michael@0 | 829 | // it is disabled by default. |
michael@0 | 830 | // Gaia is then using @x-inputmode has its proprietary way to set |
michael@0 | 831 | // inputmode for fields. This shouldn't be used outside of pre-installed |
michael@0 | 832 | // apps because the attribute is going to disappear as soon as a definitive |
michael@0 | 833 | // solution will be find. |
michael@0 | 834 | let inputmode = element.getAttribute('x-inputmode'); |
michael@0 | 835 | if (inputmode) { |
michael@0 | 836 | inputmode = inputmode.toLowerCase(); |
michael@0 | 837 | } else { |
michael@0 | 838 | inputmode = ''; |
michael@0 | 839 | } |
michael@0 | 840 | |
michael@0 | 841 | let range = getSelectionRange(element); |
michael@0 | 842 | let textAround = getTextAroundCursor(value, range); |
michael@0 | 843 | |
michael@0 | 844 | return { |
michael@0 | 845 | "contextId": focusCounter, |
michael@0 | 846 | |
michael@0 | 847 | "type": type.toLowerCase(), |
michael@0 | 848 | "choices": getListForElement(element), |
michael@0 | 849 | "value": value, |
michael@0 | 850 | "inputmode": inputmode, |
michael@0 | 851 | "selectionStart": range[0], |
michael@0 | 852 | "selectionEnd": range[1], |
michael@0 | 853 | "max": max, |
michael@0 | 854 | "min": min, |
michael@0 | 855 | "lang": element.lang || "", |
michael@0 | 856 | "textBeforeCursor": textAround.before, |
michael@0 | 857 | "textAfterCursor": textAround.after |
michael@0 | 858 | }; |
michael@0 | 859 | } |
michael@0 | 860 | |
michael@0 | 861 | function getTextAroundCursor(value, range) { |
michael@0 | 862 | let textBeforeCursor = range[0] < 100 ? |
michael@0 | 863 | value.substr(0, range[0]) : |
michael@0 | 864 | value.substr(range[0] - 100, 100); |
michael@0 | 865 | |
michael@0 | 866 | let textAfterCursor = range[1] + 100 > value.length ? |
michael@0 | 867 | value.substr(range[0], value.length) : |
michael@0 | 868 | value.substr(range[0], range[1] - range[0] + 100); |
michael@0 | 869 | |
michael@0 | 870 | return { |
michael@0 | 871 | before: textBeforeCursor, |
michael@0 | 872 | after: textAfterCursor |
michael@0 | 873 | }; |
michael@0 | 874 | } |
michael@0 | 875 | |
michael@0 | 876 | function getListForElement(element) { |
michael@0 | 877 | if (!(element instanceof HTMLSelectElement)) |
michael@0 | 878 | return null; |
michael@0 | 879 | |
michael@0 | 880 | let optionIndex = 0; |
michael@0 | 881 | let result = { |
michael@0 | 882 | "multiple": element.multiple, |
michael@0 | 883 | "choices": [] |
michael@0 | 884 | }; |
michael@0 | 885 | |
michael@0 | 886 | // Build up a flat JSON array of the choices. |
michael@0 | 887 | // In HTML, it's possible for select element choices to be under a |
michael@0 | 888 | // group header (but not recursively). We distinguish between headers |
michael@0 | 889 | // and entries using the boolean "list.group". |
michael@0 | 890 | let children = element.children; |
michael@0 | 891 | for (let i = 0; i < children.length; i++) { |
michael@0 | 892 | let child = children[i]; |
michael@0 | 893 | |
michael@0 | 894 | if (child instanceof HTMLOptGroupElement) { |
michael@0 | 895 | result.choices.push({ |
michael@0 | 896 | "group": true, |
michael@0 | 897 | "text": child.label || child.firstChild.data, |
michael@0 | 898 | "disabled": child.disabled |
michael@0 | 899 | }); |
michael@0 | 900 | |
michael@0 | 901 | let subchildren = child.children; |
michael@0 | 902 | for (let j = 0; j < subchildren.length; j++) { |
michael@0 | 903 | let subchild = subchildren[j]; |
michael@0 | 904 | result.choices.push({ |
michael@0 | 905 | "group": false, |
michael@0 | 906 | "inGroup": true, |
michael@0 | 907 | "text": subchild.text, |
michael@0 | 908 | "disabled": child.disabled || subchild.disabled, |
michael@0 | 909 | "selected": subchild.selected, |
michael@0 | 910 | "optionIndex": optionIndex++ |
michael@0 | 911 | }); |
michael@0 | 912 | } |
michael@0 | 913 | } else if (child instanceof HTMLOptionElement) { |
michael@0 | 914 | result.choices.push({ |
michael@0 | 915 | "group": false, |
michael@0 | 916 | "inGroup": false, |
michael@0 | 917 | "text": child.text, |
michael@0 | 918 | "disabled": child.disabled, |
michael@0 | 919 | "selected": child.selected, |
michael@0 | 920 | "optionIndex": optionIndex++ |
michael@0 | 921 | }); |
michael@0 | 922 | } |
michael@0 | 923 | } |
michael@0 | 924 | |
michael@0 | 925 | return result; |
michael@0 | 926 | }; |
michael@0 | 927 | |
michael@0 | 928 | // Create a plain text document encode from the focused element. |
michael@0 | 929 | function getDocumentEncoder(element) { |
michael@0 | 930 | let encoder = Cc["@mozilla.org/layout/documentEncoder;1?type=text/plain"] |
michael@0 | 931 | .createInstance(Ci.nsIDocumentEncoder); |
michael@0 | 932 | let flags = Ci.nsIDocumentEncoder.SkipInvisibleContent | |
michael@0 | 933 | Ci.nsIDocumentEncoder.OutputRaw | |
michael@0 | 934 | Ci.nsIDocumentEncoder.OutputDropInvisibleBreak | |
michael@0 | 935 | // Bug 902847. Don't trim trailing spaces of a line. |
michael@0 | 936 | Ci.nsIDocumentEncoder.OutputDontRemoveLineEndingSpaces | |
michael@0 | 937 | Ci.nsIDocumentEncoder.OutputLFLineBreak | |
michael@0 | 938 | Ci.nsIDocumentEncoder.OutputNonTextContentAsPlaceholder; |
michael@0 | 939 | encoder.init(element.ownerDocument, "text/plain", flags); |
michael@0 | 940 | return encoder; |
michael@0 | 941 | } |
michael@0 | 942 | |
michael@0 | 943 | // Get the visible content text of a content editable element |
michael@0 | 944 | function getContentEditableText(element) { |
michael@0 | 945 | if (!element || !isContentEditable(element)) { |
michael@0 | 946 | return null; |
michael@0 | 947 | } |
michael@0 | 948 | |
michael@0 | 949 | let doc = element.ownerDocument; |
michael@0 | 950 | let range = doc.createRange(); |
michael@0 | 951 | range.selectNodeContents(element); |
michael@0 | 952 | let encoder = FormAssistant.documentEncoder; |
michael@0 | 953 | encoder.setRange(range); |
michael@0 | 954 | return encoder.encodeToString(); |
michael@0 | 955 | } |
michael@0 | 956 | |
michael@0 | 957 | function getSelectionRange(element) { |
michael@0 | 958 | let start = 0; |
michael@0 | 959 | let end = 0; |
michael@0 | 960 | if (isPlainTextField(element)) { |
michael@0 | 961 | // Get the selection range of <input> and <textarea> elements |
michael@0 | 962 | start = element.selectionStart; |
michael@0 | 963 | end = element.selectionEnd; |
michael@0 | 964 | } else if (isContentEditable(element)){ |
michael@0 | 965 | // Get the selection range of contenteditable elements |
michael@0 | 966 | let win = element.ownerDocument.defaultView; |
michael@0 | 967 | let sel = win.getSelection(); |
michael@0 | 968 | if (sel && sel.rangeCount > 0) { |
michael@0 | 969 | start = getContentEditableSelectionStart(element, sel); |
michael@0 | 970 | end = start + getContentEditableSelectionLength(element, sel); |
michael@0 | 971 | } else { |
michael@0 | 972 | dump("Failed to get window.getSelection()\n"); |
michael@0 | 973 | } |
michael@0 | 974 | } |
michael@0 | 975 | return [start, end]; |
michael@0 | 976 | } |
michael@0 | 977 | |
michael@0 | 978 | function getContentEditableSelectionStart(element, selection) { |
michael@0 | 979 | let doc = element.ownerDocument; |
michael@0 | 980 | let range = doc.createRange(); |
michael@0 | 981 | range.setStart(element, 0); |
michael@0 | 982 | range.setEnd(selection.anchorNode, selection.anchorOffset); |
michael@0 | 983 | let encoder = FormAssistant.documentEncoder; |
michael@0 | 984 | encoder.setRange(range); |
michael@0 | 985 | return encoder.encodeToString().length; |
michael@0 | 986 | } |
michael@0 | 987 | |
michael@0 | 988 | function getContentEditableSelectionLength(element, selection) { |
michael@0 | 989 | let encoder = FormAssistant.documentEncoder; |
michael@0 | 990 | encoder.setRange(selection.getRangeAt(0)); |
michael@0 | 991 | return encoder.encodeToString().length; |
michael@0 | 992 | } |
michael@0 | 993 | |
michael@0 | 994 | function setSelectionRange(element, start, end) { |
michael@0 | 995 | let isTextField = isPlainTextField(element); |
michael@0 | 996 | |
michael@0 | 997 | // Check the parameters |
michael@0 | 998 | |
michael@0 | 999 | if (!isTextField && !isContentEditable(element)) { |
michael@0 | 1000 | // Skip HTMLOptionElement and HTMLSelectElement elements, as they don't |
michael@0 | 1001 | // support the operation of setSelectionRange |
michael@0 | 1002 | return false; |
michael@0 | 1003 | } |
michael@0 | 1004 | |
michael@0 | 1005 | let text = isTextField ? element.value : getContentEditableText(element); |
michael@0 | 1006 | let length = text.length; |
michael@0 | 1007 | if (start < 0) { |
michael@0 | 1008 | start = 0; |
michael@0 | 1009 | } |
michael@0 | 1010 | if (end > length) { |
michael@0 | 1011 | end = length; |
michael@0 | 1012 | } |
michael@0 | 1013 | if (start > end) { |
michael@0 | 1014 | start = end; |
michael@0 | 1015 | } |
michael@0 | 1016 | |
michael@0 | 1017 | if (isTextField) { |
michael@0 | 1018 | // Set the selection range of <input> and <textarea> elements |
michael@0 | 1019 | element.setSelectionRange(start, end, "forward"); |
michael@0 | 1020 | return true; |
michael@0 | 1021 | } else { |
michael@0 | 1022 | // set the selection range of contenteditable elements |
michael@0 | 1023 | let win = element.ownerDocument.defaultView; |
michael@0 | 1024 | let sel = win.getSelection(); |
michael@0 | 1025 | |
michael@0 | 1026 | // Move the caret to the start position |
michael@0 | 1027 | sel.collapse(element, 0); |
michael@0 | 1028 | for (let i = 0; i < start; i++) { |
michael@0 | 1029 | sel.modify("move", "forward", "character"); |
michael@0 | 1030 | } |
michael@0 | 1031 | |
michael@0 | 1032 | // Avoid entering infinite loop in case we cannot change the selection |
michael@0 | 1033 | // range. See bug https://bugzilla.mozilla.org/show_bug.cgi?id=978918 |
michael@0 | 1034 | let oldStart = getContentEditableSelectionStart(element, sel); |
michael@0 | 1035 | let counter = 0; |
michael@0 | 1036 | while (oldStart < start) { |
michael@0 | 1037 | sel.modify("move", "forward", "character"); |
michael@0 | 1038 | let newStart = getContentEditableSelectionStart(element, sel); |
michael@0 | 1039 | if (oldStart == newStart) { |
michael@0 | 1040 | counter++; |
michael@0 | 1041 | if (counter > MAX_BLOCKED_COUNT) { |
michael@0 | 1042 | return false; |
michael@0 | 1043 | } |
michael@0 | 1044 | } else { |
michael@0 | 1045 | counter = 0; |
michael@0 | 1046 | oldStart = newStart; |
michael@0 | 1047 | } |
michael@0 | 1048 | } |
michael@0 | 1049 | |
michael@0 | 1050 | // Extend the selection to the end position |
michael@0 | 1051 | for (let i = start; i < end; i++) { |
michael@0 | 1052 | sel.modify("extend", "forward", "character"); |
michael@0 | 1053 | } |
michael@0 | 1054 | |
michael@0 | 1055 | // Avoid entering infinite loop in case we cannot change the selection |
michael@0 | 1056 | // range. See bug https://bugzilla.mozilla.org/show_bug.cgi?id=978918 |
michael@0 | 1057 | counter = 0; |
michael@0 | 1058 | let selectionLength = end - start; |
michael@0 | 1059 | let oldSelectionLength = getContentEditableSelectionLength(element, sel); |
michael@0 | 1060 | while (oldSelectionLength < selectionLength) { |
michael@0 | 1061 | sel.modify("extend", "forward", "character"); |
michael@0 | 1062 | let newSelectionLength = getContentEditableSelectionLength(element, sel); |
michael@0 | 1063 | if (oldSelectionLength == newSelectionLength ) { |
michael@0 | 1064 | counter++; |
michael@0 | 1065 | if (counter > MAX_BLOCKED_COUNT) { |
michael@0 | 1066 | return false; |
michael@0 | 1067 | } |
michael@0 | 1068 | } else { |
michael@0 | 1069 | counter = 0; |
michael@0 | 1070 | oldSelectionLength = newSelectionLength; |
michael@0 | 1071 | } |
michael@0 | 1072 | } |
michael@0 | 1073 | return true; |
michael@0 | 1074 | } |
michael@0 | 1075 | } |
michael@0 | 1076 | |
michael@0 | 1077 | /** |
michael@0 | 1078 | * Scroll the given element into view. |
michael@0 | 1079 | * |
michael@0 | 1080 | * Calls scrollSelectionIntoView for contentEditable elements. |
michael@0 | 1081 | */ |
michael@0 | 1082 | function scrollSelectionOrElementIntoView(element) { |
michael@0 | 1083 | let editor = getPlaintextEditor(element); |
michael@0 | 1084 | if (editor) { |
michael@0 | 1085 | editor.selectionController.scrollSelectionIntoView( |
michael@0 | 1086 | Ci.nsISelectionController.SELECTION_NORMAL, |
michael@0 | 1087 | Ci.nsISelectionController.SELECTION_FOCUS_REGION, |
michael@0 | 1088 | Ci.nsISelectionController.SCROLL_SYNCHRONOUS); |
michael@0 | 1089 | } else { |
michael@0 | 1090 | element.scrollIntoView(false); |
michael@0 | 1091 | } |
michael@0 | 1092 | } |
michael@0 | 1093 | |
michael@0 | 1094 | // Get nsIPlaintextEditor object from an input field |
michael@0 | 1095 | function getPlaintextEditor(element) { |
michael@0 | 1096 | let editor = null; |
michael@0 | 1097 | // Get nsIEditor |
michael@0 | 1098 | if (isPlainTextField(element)) { |
michael@0 | 1099 | // Get from the <input> and <textarea> elements |
michael@0 | 1100 | editor = element.QueryInterface(Ci.nsIDOMNSEditableElement).editor; |
michael@0 | 1101 | } else if (isContentEditable(element)) { |
michael@0 | 1102 | // Get from content editable element |
michael@0 | 1103 | let win = element.ownerDocument.defaultView; |
michael@0 | 1104 | let editingSession = win.QueryInterface(Ci.nsIInterfaceRequestor) |
michael@0 | 1105 | .getInterface(Ci.nsIWebNavigation) |
michael@0 | 1106 | .QueryInterface(Ci.nsIInterfaceRequestor) |
michael@0 | 1107 | .getInterface(Ci.nsIEditingSession); |
michael@0 | 1108 | if (editingSession) { |
michael@0 | 1109 | editor = editingSession.getEditorForWindow(win); |
michael@0 | 1110 | } |
michael@0 | 1111 | } |
michael@0 | 1112 | if (editor) { |
michael@0 | 1113 | editor.QueryInterface(Ci.nsIPlaintextEditor); |
michael@0 | 1114 | } |
michael@0 | 1115 | return editor; |
michael@0 | 1116 | } |
michael@0 | 1117 | |
michael@0 | 1118 | function replaceSurroundingText(element, text, selectionStart, selectionEnd, |
michael@0 | 1119 | offset, length) { |
michael@0 | 1120 | let editor = FormAssistant.editor; |
michael@0 | 1121 | if (!editor) { |
michael@0 | 1122 | return false; |
michael@0 | 1123 | } |
michael@0 | 1124 | |
michael@0 | 1125 | // Check the parameters. |
michael@0 | 1126 | let start = selectionStart + offset; |
michael@0 | 1127 | if (start < 0) { |
michael@0 | 1128 | start = 0; |
michael@0 | 1129 | } |
michael@0 | 1130 | if (length < 0) { |
michael@0 | 1131 | length = 0; |
michael@0 | 1132 | } |
michael@0 | 1133 | let end = start + length; |
michael@0 | 1134 | |
michael@0 | 1135 | if (selectionStart != start || selectionEnd != end) { |
michael@0 | 1136 | // Change selection range before replacing. |
michael@0 | 1137 | if (!setSelectionRange(element, start, end)) { |
michael@0 | 1138 | return false; |
michael@0 | 1139 | } |
michael@0 | 1140 | } |
michael@0 | 1141 | |
michael@0 | 1142 | if (start != end) { |
michael@0 | 1143 | // Delete the selected text. |
michael@0 | 1144 | editor.deleteSelection(Ci.nsIEditor.ePrevious, Ci.nsIEditor.eStrip); |
michael@0 | 1145 | } |
michael@0 | 1146 | |
michael@0 | 1147 | if (text) { |
michael@0 | 1148 | // We don't use CR but LF |
michael@0 | 1149 | // see https://bugzilla.mozilla.org/show_bug.cgi?id=902847 |
michael@0 | 1150 | text = text.replace(/\r/g, '\n'); |
michael@0 | 1151 | // Insert the text to be replaced with. |
michael@0 | 1152 | editor.insertText(text); |
michael@0 | 1153 | } |
michael@0 | 1154 | return true; |
michael@0 | 1155 | } |
michael@0 | 1156 | |
michael@0 | 1157 | let CompositionManager = { |
michael@0 | 1158 | _isStarted: false, |
michael@0 | 1159 | _text: '', |
michael@0 | 1160 | _clauseAttrMap: { |
michael@0 | 1161 | 'raw-input': |
michael@0 | 1162 | Ci.nsICompositionStringSynthesizer.ATTR_RAWINPUT, |
michael@0 | 1163 | 'selected-raw-text': |
michael@0 | 1164 | Ci.nsICompositionStringSynthesizer.ATTR_SELECTEDRAWTEXT, |
michael@0 | 1165 | 'converted-text': |
michael@0 | 1166 | Ci.nsICompositionStringSynthesizer.ATTR_CONVERTEDTEXT, |
michael@0 | 1167 | 'selected-converted-text': |
michael@0 | 1168 | Ci.nsICompositionStringSynthesizer.ATTR_SELECTEDCONVERTEDTEXT |
michael@0 | 1169 | }, |
michael@0 | 1170 | |
michael@0 | 1171 | setComposition: function cm_setComposition(element, text, cursor, clauses) { |
michael@0 | 1172 | // Check parameters. |
michael@0 | 1173 | if (!element) { |
michael@0 | 1174 | return; |
michael@0 | 1175 | } |
michael@0 | 1176 | let len = text.length; |
michael@0 | 1177 | if (cursor > len) { |
michael@0 | 1178 | cursor = len; |
michael@0 | 1179 | } |
michael@0 | 1180 | let clauseLens = []; |
michael@0 | 1181 | let clauseAttrs = []; |
michael@0 | 1182 | if (clauses) { |
michael@0 | 1183 | let remainingLength = len; |
michael@0 | 1184 | for (let i = 0; i < clauses.length; i++) { |
michael@0 | 1185 | if (clauses[i]) { |
michael@0 | 1186 | let clauseLength = clauses[i].length || 0; |
michael@0 | 1187 | // Make sure the total clauses length is not bigger than that of the |
michael@0 | 1188 | // composition string. |
michael@0 | 1189 | if (clauseLength > remainingLength) { |
michael@0 | 1190 | clauseLength = remainingLength; |
michael@0 | 1191 | } |
michael@0 | 1192 | remainingLength -= clauseLength; |
michael@0 | 1193 | clauseLens.push(clauseLength); |
michael@0 | 1194 | clauseAttrs.push(this._clauseAttrMap[clauses[i].selectionType] || |
michael@0 | 1195 | Ci.nsICompositionStringSynthesizer.ATTR_RAWINPUT); |
michael@0 | 1196 | } |
michael@0 | 1197 | } |
michael@0 | 1198 | // If the total clauses length is less than that of the composition |
michael@0 | 1199 | // string, extend the last clause to the end of the composition string. |
michael@0 | 1200 | if (remainingLength > 0) { |
michael@0 | 1201 | clauseLens[clauseLens.length - 1] += remainingLength; |
michael@0 | 1202 | } |
michael@0 | 1203 | } else { |
michael@0 | 1204 | clauseLens.push(len); |
michael@0 | 1205 | clauseAttrs.push(Ci.nsICompositionStringSynthesizer.ATTR_RAWINPUT); |
michael@0 | 1206 | } |
michael@0 | 1207 | |
michael@0 | 1208 | // Start composition if need to. |
michael@0 | 1209 | if (!this._isStarted) { |
michael@0 | 1210 | this._isStarted = true; |
michael@0 | 1211 | domWindowUtils.sendCompositionEvent('compositionstart', '', ''); |
michael@0 | 1212 | this._text = ''; |
michael@0 | 1213 | } |
michael@0 | 1214 | |
michael@0 | 1215 | // Update the composing text. |
michael@0 | 1216 | if (this._text !== text) { |
michael@0 | 1217 | this._text = text; |
michael@0 | 1218 | domWindowUtils.sendCompositionEvent('compositionupdate', text, ''); |
michael@0 | 1219 | } |
michael@0 | 1220 | let compositionString = domWindowUtils.createCompositionStringSynthesizer(); |
michael@0 | 1221 | compositionString.setString(text); |
michael@0 | 1222 | for (var i = 0; i < clauseLens.length; i++) { |
michael@0 | 1223 | compositionString.appendClause(clauseLens[i], clauseAttrs[i]); |
michael@0 | 1224 | } |
michael@0 | 1225 | if (cursor >= 0) { |
michael@0 | 1226 | compositionString.setCaret(cursor, 0); |
michael@0 | 1227 | } |
michael@0 | 1228 | compositionString.dispatchEvent(); |
michael@0 | 1229 | }, |
michael@0 | 1230 | |
michael@0 | 1231 | endComposition: function cm_endComposition(text) { |
michael@0 | 1232 | if (!this._isStarted) { |
michael@0 | 1233 | return; |
michael@0 | 1234 | } |
michael@0 | 1235 | // Update the composing text. |
michael@0 | 1236 | if (this._text !== text) { |
michael@0 | 1237 | domWindowUtils.sendCompositionEvent('compositionupdate', text, ''); |
michael@0 | 1238 | } |
michael@0 | 1239 | let compositionString = domWindowUtils.createCompositionStringSynthesizer(); |
michael@0 | 1240 | compositionString.setString(text); |
michael@0 | 1241 | // Set the cursor position to |text.length| so that the text will be |
michael@0 | 1242 | // committed before the cursor position. |
michael@0 | 1243 | compositionString.setCaret(text.length, 0); |
michael@0 | 1244 | compositionString.dispatchEvent(); |
michael@0 | 1245 | domWindowUtils.sendCompositionEvent('compositionend', text, ''); |
michael@0 | 1246 | this._text = ''; |
michael@0 | 1247 | this._isStarted = false; |
michael@0 | 1248 | }, |
michael@0 | 1249 | |
michael@0 | 1250 | // Composition ends due to external actions. |
michael@0 | 1251 | onCompositionEnd: function cm_onCompositionEnd() { |
michael@0 | 1252 | if (!this._isStarted) { |
michael@0 | 1253 | return; |
michael@0 | 1254 | } |
michael@0 | 1255 | |
michael@0 | 1256 | this._text = ''; |
michael@0 | 1257 | this._isStarted = false; |
michael@0 | 1258 | } |
michael@0 | 1259 | }; |