2 * Copyright (C) 2008 Apple Inc. All rights reserved.
3 * Copyright (C) 2011 Google Inc. All rights reserved.
5 * Redistribution and use in source and binary forms, with or without
6 * modification, are permitted provided that the following conditions
9 * 1. Redistributions of source code must retain the above copyright
10 * notice, this list of conditions and the following disclaimer.
11 * 2. Redistributions in binary form must reproduce the above copyright
12 * notice, this list of conditions and the following disclaimer in the
13 * documentation and/or other materials provided with the distribution.
14 * 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of
15 * its contributors may be used to endorse or promote products derived
16 * from this software without specific prior written permission.
18 * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
19 * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
22 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
25 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
27 * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
32 * @extends {WebInspector.Object}
33 * @implements {WebInspector.SuggestBoxDelegate}
34 * @param {function(!Element, !Range, boolean, function(!Array.<string>, number=))} completions
35 * @param {string=} stopCharacters
37 WebInspector.TextPrompt = function(completions, stopCharacters)
40 * @type {!Element|undefined}
43 this._proxyElementDisplay = "inline-block";
44 this._loadCompletions = completions;
45 this._completionStopCharacters = stopCharacters || " =:[({;,!+-*/&|^<>.";
46 this._autocompletionTimeout = WebInspector.TextPrompt.DefaultAutocompletionTimeout;
49 WebInspector.TextPrompt.DefaultAutocompletionTimeout = 250;
51 WebInspector.TextPrompt.Events = {
52 ItemApplied: "text-prompt-item-applied",
53 ItemAccepted: "text-prompt-item-accepted"
56 WebInspector.TextPrompt.prototype = {
58 * @param {number} timeout
60 setAutocompletionTimeout: function(timeout)
62 this._autocompletionTimeout = timeout;
67 return this._proxyElement;
71 * @param {boolean} suggestBoxEnabled
73 setSuggestBoxEnabled: function(suggestBoxEnabled)
75 this._suggestBoxEnabled = suggestBoxEnabled;
78 renderAsBlock: function()
80 this._proxyElementDisplay = "block";
84 * Clients should never attach any event listeners to the |element|. Instead,
85 * they should use the result of this method to attach listeners for bubbling events.
87 * @param {!Element} element
90 attach: function(element)
92 return this._attachInternal(element);
96 * Clients should never attach any event listeners to the |element|. Instead,
97 * they should use the result of this method to attach listeners for bubbling events
98 * or the |blurListener| parameter to register a "blur" event listener on the |element|
99 * (since the "blur" event does not bubble.)
101 * @param {!Element} element
102 * @param {function(!Event)} blurListener
105 attachAndStartEditing: function(element, blurListener)
107 this._attachInternal(element);
108 this._startEditing(blurListener);
109 return this.proxyElement;
113 * @param {!Element} element
116 _attachInternal: function(element)
118 if (this.proxyElement)
119 throw "Cannot attach an attached TextPrompt";
120 this._element = element;
122 this._boundOnKeyDown = this.onKeyDown.bind(this);
123 this._boundOnInput = this.onInput.bind(this);
124 this._boundOnMouseWheel = this.onMouseWheel.bind(this);
125 this._boundSelectStart = this._selectStart.bind(this);
126 this._boundRemoveSuggestionAids = this._removeSuggestionAids.bind(this);
127 this._proxyElement = element.ownerDocument.createElement("span");
128 this._proxyElement.style.display = this._proxyElementDisplay;
129 element.parentElement.insertBefore(this.proxyElement, element);
130 this.proxyElement.appendChild(element);
131 this._element.classList.add("text-prompt");
132 this._element.addEventListener("keydown", this._boundOnKeyDown, false);
133 this._element.addEventListener("input", this._boundOnInput, false);
134 this._element.addEventListener("mousewheel", this._boundOnMouseWheel, false);
135 this._element.addEventListener("selectstart", this._boundSelectStart, false);
136 this._element.addEventListener("blur", this._boundRemoveSuggestionAids, false);
137 this._element.ownerDocument.defaultView.addEventListener("resize", this._boundRemoveSuggestionAids, false);
139 if (this._suggestBoxEnabled)
140 this._suggestBox = new WebInspector.SuggestBox(this);
142 return this.proxyElement;
147 this._removeFromElement();
148 this.proxyElement.parentElement.insertBefore(this._element, this.proxyElement);
149 this.proxyElement.remove();
150 delete this._proxyElement;
151 this._element.classList.remove("text-prompt");
152 WebInspector.restoreFocusFromElement(this._element);
160 return this._element.textContent;
168 this._removeSuggestionAids();
170 // Append a break element instead of setting textContent to make sure the selection is inside the prompt.
171 this._element.removeChildren();
172 this._element.createChild("br");
174 this._element.textContent = x;
177 this.moveCaretToEndOfPrompt();
178 this._element.scrollIntoView();
181 _removeFromElement: function()
183 this.clearAutoComplete(true);
184 this._element.removeEventListener("keydown", this._boundOnKeyDown, false);
185 this._element.removeEventListener("input", this._boundOnInput, false);
186 this._element.removeEventListener("selectstart", this._boundSelectStart, false);
187 this._element.removeEventListener("blur", this._boundRemoveSuggestionAids, false);
188 this._element.ownerDocument.defaultView.removeEventListener("resize", this._boundRemoveSuggestionAids, false);
191 if (this._suggestBox)
192 this._suggestBox.removeFromElement();
196 * @param {function(!Event)=} blurListener
198 _startEditing: function(blurListener)
200 this._isEditing = true;
201 this._element.classList.add("editing");
203 this._blurListener = blurListener;
204 this._element.addEventListener("blur", this._blurListener, false);
206 this._oldTabIndex = this._element.tabIndex;
207 if (this._element.tabIndex < 0)
208 this._element.tabIndex = 0;
209 WebInspector.setCurrentFocusElement(this._element);
211 this._updateAutoComplete();
214 _stopEditing: function()
216 this._element.tabIndex = this._oldTabIndex;
217 if (this._blurListener)
218 this._element.removeEventListener("blur", this._blurListener, false);
219 this._element.classList.remove("editing");
220 delete this._isEditing;
223 _removeSuggestionAids: function()
225 this.clearAutoComplete();
226 this.hideSuggestBox();
229 _selectStart: function()
231 if (this._selectionTimeout)
232 clearTimeout(this._selectionTimeout);
234 this._removeSuggestionAids();
237 * @this {WebInspector.TextPrompt}
239 function moveBackIfOutside()
241 delete this._selectionTimeout;
242 if (!this.isCaretInsidePrompt() && window.getSelection().isCollapsed) {
243 this.moveCaretToEndOfPrompt();
244 this.autoCompleteSoon();
248 this._selectionTimeout = setTimeout(moveBackIfOutside.bind(this), 100);
252 * @param {boolean=} force
254 _updateAutoComplete: function(force)
256 this.clearAutoComplete();
257 this.autoCompleteSoon(force);
261 * @param {!Event} event
263 onMouseWheel: function(event)
265 // Subclasses can implement.
269 * @param {!Event} event
271 onKeyDown: function(event)
274 delete this._needUpdateAutocomplete;
276 switch (event.keyIdentifier) {
277 case "U+0009": // Tab
278 handled = this.tabKeyPressed(event);
282 this._removeSuggestionAids();
286 if (this.isCaretAtEndOfPrompt())
287 handled = this.acceptAutoComplete();
289 this._removeSuggestionAids();
291 case "U+001B": // Esc
292 if (this.isSuggestBoxVisible()) {
293 this._removeSuggestionAids();
297 case "U+0020": // Space
298 if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
299 this._updateAutoComplete(true);
310 if (!handled && this.isSuggestBoxVisible())
311 handled = this._suggestBox.keyPressed(event);
314 this._needUpdateAutocomplete = true;
321 * @param {!Event} event
323 onInput: function(event)
325 if (this._needUpdateAutocomplete)
326 this._updateAutoComplete();
332 acceptAutoComplete: function()
335 if (this.isSuggestBoxVisible())
336 result = this._suggestBox.acceptSuggestion();
338 result = this._acceptSuggestionInternal();
344 * @param {boolean=} includeTimeout
346 clearAutoComplete: function(includeTimeout)
348 if (includeTimeout && this._completeTimeout) {
349 clearTimeout(this._completeTimeout);
350 delete this._completeTimeout;
352 delete this._waitingForCompletions;
354 if (!this.autoCompleteElement)
357 this.autoCompleteElement.remove();
358 delete this.autoCompleteElement;
359 delete this._userEnteredRange;
360 delete this._userEnteredText;
364 * @param {boolean=} force
366 autoCompleteSoon: function(force)
368 var immediately = this.isSuggestBoxVisible() || force;
369 if (!this._completeTimeout)
370 this._completeTimeout = setTimeout(this.complete.bind(this, force), immediately ? 0 : this._autocompletionTimeout);
374 * @param {boolean=} force
375 * @param {boolean=} reverse
377 complete: function(force, reverse)
379 this.clearAutoComplete(true);
380 var selection = window.getSelection();
381 if (!selection.rangeCount)
384 var selectionRange = selection.getRangeAt(0);
387 if (!force && !this.isCaretAtEndOfPrompt() && !this.isSuggestBoxVisible())
389 else if (!selection.isCollapsed)
392 // BUG72018: Do not show suggest box if caret is followed by a non-stop character.
393 var wordSuffixRange = selectionRange.startContainer.rangeOfWord(selectionRange.endOffset, this._completionStopCharacters, this._element, "forward");
394 if (wordSuffixRange.toString().length)
398 this.hideSuggestBox();
402 var wordPrefixRange = selectionRange.startContainer.rangeOfWord(selectionRange.startOffset, this._completionStopCharacters, this._element, "backward");
403 this._waitingForCompletions = true;
404 this._loadCompletions(this.proxyElement, wordPrefixRange, force || false, this._completionsReady.bind(this, selection, wordPrefixRange, !!reverse));
407 disableDefaultSuggestionForEmptyInput: function()
409 this._disableDefaultSuggestionForEmptyInput = true;
413 * @param {!Selection} selection
414 * @param {!Range} textRange
416 _boxForAnchorAtStart: function(selection, textRange)
418 var rangeCopy = selection.getRangeAt(0).cloneRange();
419 var anchorElement = createElement("span");
420 anchorElement.textContent = "\u200B";
421 textRange.insertNode(anchorElement);
422 var box = anchorElement.boxInWindow(window);
423 anchorElement.remove();
424 selection.removeAllRanges();
425 selection.addRange(rangeCopy);
430 * @param {!Array.<string>} completions
431 * @param {number} wordPrefixLength
433 _buildCommonPrefix: function(completions, wordPrefixLength)
435 var commonPrefix = completions[0];
436 for (var i = 0; i < completions.length; ++i) {
437 var completion = completions[i];
438 var lastIndex = Math.min(commonPrefix.length, completion.length);
439 for (var j = wordPrefixLength; j < lastIndex; ++j) {
440 if (commonPrefix[j] !== completion[j]) {
441 commonPrefix = commonPrefix.substr(0, j);
451 * @suppressGlobalPropertiesCheck
453 _createRange: function()
455 return document.createRange();
459 * @param {!Selection} selection
460 * @param {!Range} originalWordPrefixRange
461 * @param {boolean} reverse
462 * @param {!Array.<string>} completions
463 * @param {number=} selectedIndex
465 _completionsReady: function(selection, originalWordPrefixRange, reverse, completions, selectedIndex)
467 if (!this._waitingForCompletions || !completions.length) {
468 this.hideSuggestBox();
471 delete this._waitingForCompletions;
473 var selectionRange = selection.getRangeAt(0);
475 var fullWordRange = this._createRange();
476 fullWordRange.setStart(originalWordPrefixRange.startContainer, originalWordPrefixRange.startOffset);
477 fullWordRange.setEnd(selectionRange.endContainer, selectionRange.endOffset);
479 if (originalWordPrefixRange.toString() + selectionRange.toString() !== fullWordRange.toString())
482 selectedIndex = (this._disableDefaultSuggestionForEmptyInput && !this.text) ? -1 : (selectedIndex || 0);
484 this._userEnteredRange = fullWordRange;
485 this._userEnteredText = fullWordRange.toString();
487 if (this._suggestBox)
488 this._suggestBox.updateSuggestions(this._boxForAnchorAtStart(selection, fullWordRange), completions, selectedIndex, !this.isCaretAtEndOfPrompt(), this._userEnteredText);
490 if (selectedIndex === -1)
493 var wordPrefixLength = originalWordPrefixRange.toString().length;
494 this._commonPrefix = this._buildCommonPrefix(completions, wordPrefixLength);
496 if (this.isCaretAtEndOfPrompt()) {
497 var completionText = completions[selectedIndex];
498 var prefixText = this._userEnteredRange.toString();
499 var suffixText = completionText.substring(wordPrefixLength);
500 this._userEnteredRange.deleteContents();
501 this._element.normalize();
502 var finalSelectionRange = this._createRange();
504 var prefixTextNode = createTextNode(prefixText);
505 fullWordRange.insertNode(prefixTextNode);
507 this.autoCompleteElement = createElementWithClass("span", "auto-complete-text");
508 this.autoCompleteElement.textContent = suffixText;
510 prefixTextNode.parentNode.insertBefore(this.autoCompleteElement, prefixTextNode.nextSibling);
512 finalSelectionRange.setStart(prefixTextNode, wordPrefixLength);
513 finalSelectionRange.setEnd(prefixTextNode, wordPrefixLength);
514 selection.removeAllRanges();
515 selection.addRange(finalSelectionRange);
516 this.dispatchEventToListeners(WebInspector.TextPrompt.Events.ItemApplied);
520 _completeCommonPrefix: function()
522 if (!this.autoCompleteElement || !this._commonPrefix || !this._userEnteredText || !this._commonPrefix.startsWith(this._userEnteredText))
525 if (!this.isSuggestBoxVisible()) {
526 this.acceptAutoComplete();
530 this.autoCompleteElement.textContent = this._commonPrefix.substring(this._userEnteredText.length);
531 this._acceptSuggestionInternal(true);
535 * @param {string} completionText
536 * @param {boolean=} isIntermediateSuggestion
538 applySuggestion: function(completionText, isIntermediateSuggestion)
540 this._applySuggestion(completionText, isIntermediateSuggestion);
544 * @param {string} completionText
545 * @param {boolean=} isIntermediateSuggestion
546 * @param {!Range=} originalPrefixRange
548 _applySuggestion: function(completionText, isIntermediateSuggestion, originalPrefixRange)
550 var wordPrefixLength;
551 if (originalPrefixRange)
552 wordPrefixLength = originalPrefixRange.toString().length;
554 wordPrefixLength = this._userEnteredText ? this._userEnteredText.length : 0;
556 this._userEnteredRange.deleteContents();
557 this._element.normalize();
558 var finalSelectionRange = this._createRange();
559 var completionTextNode = createTextNode(completionText);
560 this._userEnteredRange.insertNode(completionTextNode);
561 if (this.autoCompleteElement) {
562 this.autoCompleteElement.remove();
563 delete this.autoCompleteElement;
566 if (isIntermediateSuggestion)
567 finalSelectionRange.setStart(completionTextNode, wordPrefixLength);
569 finalSelectionRange.setStart(completionTextNode, completionText.length);
571 finalSelectionRange.setEnd(completionTextNode, completionText.length);
573 var selection = window.getSelection();
574 selection.removeAllRanges();
575 selection.addRange(finalSelectionRange);
576 if (isIntermediateSuggestion)
577 this.dispatchEventToListeners(WebInspector.TextPrompt.Events.ItemApplied, { itemText: completionText });
583 acceptSuggestion: function()
585 this._acceptSuggestionInternal();
589 * @param {boolean=} prefixAccepted
592 _acceptSuggestionInternal: function(prefixAccepted)
594 if (!this.autoCompleteElement || !this.autoCompleteElement.parentNode)
597 var text = this.autoCompleteElement.textContent;
598 var textNode = createTextNode(text);
599 this.autoCompleteElement.parentNode.replaceChild(textNode, this.autoCompleteElement);
600 delete this.autoCompleteElement;
602 var finalSelectionRange = this._createRange();
603 finalSelectionRange.setStart(textNode, text.length);
604 finalSelectionRange.setEnd(textNode, text.length);
606 var selection = window.getSelection();
607 selection.removeAllRanges();
608 selection.addRange(finalSelectionRange);
610 if (!prefixAccepted) {
611 this.hideSuggestBox();
612 this.dispatchEventToListeners(WebInspector.TextPrompt.Events.ItemAccepted);
614 this.autoCompleteSoon(true);
619 hideSuggestBox: function()
621 if (this.isSuggestBoxVisible())
622 this._suggestBox.hide();
628 isSuggestBoxVisible: function()
630 return this._suggestBox && this._suggestBox.visible();
636 isCaretInsidePrompt: function()
638 return this._element.isInsertionCaretInside();
644 isCaretAtEndOfPrompt: function()
646 var selection = window.getSelection();
647 if (!selection.rangeCount || !selection.isCollapsed)
650 var selectionRange = selection.getRangeAt(0);
651 var node = selectionRange.startContainer;
652 if (!node.isSelfOrDescendant(this._element))
655 if (node.nodeType === Node.TEXT_NODE && selectionRange.startOffset < node.nodeValue.length)
658 var foundNextText = false;
660 if (node.nodeType === Node.TEXT_NODE && node.nodeValue.length) {
661 if (foundNextText && (!this.autoCompleteElement || !this.autoCompleteElement.isAncestor(node)))
663 foundNextText = true;
666 node = node.traverseNextNode(this._element);
675 isCaretOnFirstLine: function()
677 var selection = window.getSelection();
678 var focusNode = selection.focusNode;
679 if (!focusNode || focusNode.nodeType !== Node.TEXT_NODE || focusNode.parentNode !== this._element)
682 if (focusNode.textContent.substring(0, selection.focusOffset).indexOf("\n") !== -1)
684 focusNode = focusNode.previousSibling;
687 if (focusNode.nodeType !== Node.TEXT_NODE)
689 if (focusNode.textContent.indexOf("\n") !== -1)
691 focusNode = focusNode.previousSibling;
700 isCaretOnLastLine: function()
702 var selection = window.getSelection();
703 var focusNode = selection.focusNode;
704 if (!focusNode || focusNode.nodeType !== Node.TEXT_NODE || focusNode.parentNode !== this._element)
707 if (focusNode.textContent.substring(selection.focusOffset).indexOf("\n") !== -1)
709 focusNode = focusNode.nextSibling;
712 if (focusNode.nodeType !== Node.TEXT_NODE)
714 if (focusNode.textContent.indexOf("\n") !== -1)
716 focusNode = focusNode.nextSibling;
722 moveCaretToEndOfPrompt: function()
724 var selection = window.getSelection();
725 var selectionRange = this._createRange();
727 var offset = this._element.childNodes.length;
728 selectionRange.setStart(this._element, offset);
729 selectionRange.setEnd(this._element, offset);
731 selection.removeAllRanges();
732 selection.addRange(selectionRange);
736 * @param {!Event} event
739 tabKeyPressed: function(event)
741 this._completeCommonPrefix();
747 __proto__: WebInspector.Object.prototype
753 * @extends {WebInspector.TextPrompt}
754 * @param {function(!Element, !Range, boolean, function(!Array.<string>, number=))} completions
755 * @param {string=} stopCharacters
757 WebInspector.TextPromptWithHistory = function(completions, stopCharacters)
759 WebInspector.TextPrompt.call(this, completions, stopCharacters);
762 * @type {!Array.<string>}
767 * 1-based entry in the history stack.
770 this._historyOffset = 1;
773 * Whether to coalesce duplicate items in the history, default is true.
776 this._coalesceHistoryDupes = true;
779 WebInspector.TextPromptWithHistory.prototype = {
781 * @return {!Array.<string>}
785 // FIXME: do we need to copy this?
792 setCoalesceHistoryDupes: function(x)
794 this._coalesceHistoryDupes = x;
798 * @param {!Array.<string>} data
800 setHistoryData: function(data)
802 this._data = [].concat(data);
803 this._historyOffset = 1;
807 * Pushes a committed text into the history.
808 * @param {string} text
810 pushHistoryItem: function(text)
812 if (this._uncommittedIsTop) {
814 delete this._uncommittedIsTop;
817 this._historyOffset = 1;
818 if (this._coalesceHistoryDupes && text === this._currentHistoryItem())
820 this._data.push(text);
824 * Pushes the current (uncommitted) text into the history.
826 _pushCurrentText: function()
828 if (this._uncommittedIsTop)
829 this._data.pop(); // Throw away obsolete uncommitted text.
830 this._uncommittedIsTop = true;
831 this.clearAutoComplete(true);
832 this._data.push(this.text);
836 * @return {string|undefined}
838 _previous: function()
840 if (this._historyOffset > this._data.length)
842 if (this._historyOffset === 1)
843 this._pushCurrentText();
844 ++this._historyOffset;
845 return this._currentHistoryItem();
849 * @return {string|undefined}
853 if (this._historyOffset === 1)
855 --this._historyOffset;
856 return this._currentHistoryItem();
860 * @return {string|undefined}
862 _currentHistoryItem: function()
864 return this._data[this._data.length - this._historyOffset];
870 onKeyDown: function(event)
875 switch (event.keyIdentifier) {
877 if (!this.isCaretOnFirstLine() || this.isSuggestBoxVisible())
879 newText = this._previous();
883 if (!this.isCaretOnLastLine() || this.isSuggestBoxVisible())
885 newText = this._next();
887 case "U+0050": // Ctrl+P = Previous
888 if (WebInspector.isMac() && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
889 newText = this._previous();
893 case "U+004E": // Ctrl+N = Next
894 if (WebInspector.isMac() && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey)
895 newText = this._next();
899 if (newText !== undefined) {
904 var firstNewlineIndex = this.text.indexOf("\n");
905 if (firstNewlineIndex === -1)
906 this.moveCaretToEndOfPrompt();
908 var selection = window.getSelection();
909 var selectionRange = this._createRange();
911 selectionRange.setStart(this._element.firstChild, firstNewlineIndex);
912 selectionRange.setEnd(this._element.firstChild, firstNewlineIndex);
914 selection.removeAllRanges();
915 selection.addRange(selectionRange);
922 WebInspector.TextPrompt.prototype.onKeyDown.apply(this, arguments);
925 __proto__: WebInspector.TextPrompt.prototype