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