blob: 303fcc981c43b77873fa5861b870f0442e10a7e8 [file] [log] [blame]
/*
* Copyright (C) 2008 Apple Inc. All rights reserved.
* Copyright (C) 2011 Google Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of
* its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/**
* @implements {UI.SuggestBoxDelegate}
* @unrestricted
*/
export default class TextPrompt extends Common.Object {
constructor() {
super();
/**
* @type {!Element|undefined}
*/
this._proxyElement;
this._proxyElementDisplay = 'inline-block';
this._autocompletionTimeout = DefaultAutocompletionTimeout;
this._title = '';
this._queryRange = null;
this._previousText = '';
this._currentSuggestion = null;
this._completionRequestId = 0;
this._ghostTextElement = createElementWithClass('span', 'auto-complete-text');
this._ghostTextElement.setAttribute('contenteditable', 'false');
UI.ARIAUtils.markAsHidden(this._ghostTextElement);
}
/**
* @param {(function(this:null, string, string, boolean=):!Promise<!UI.SuggestBox.Suggestions>)} completions
* @param {string=} stopCharacters
*/
initialize(completions, stopCharacters) {
this._loadCompletions = completions;
this._completionStopCharacters = stopCharacters || ' =:[({;,!+-*/&|^<>.';
}
/**
* @param {number} timeout
*/
setAutocompletionTimeout(timeout) {
this._autocompletionTimeout = timeout;
}
renderAsBlock() {
this._proxyElementDisplay = 'block';
}
/**
* Clients should never attach any event listeners to the |element|. Instead,
* they should use the result of this method to attach listeners for bubbling events.
*
* @param {!Element} element
* @return {!Element}
*/
attach(element) {
return this._attachInternal(element);
}
/**
* Clients should never attach any event listeners to the |element|. Instead,
* they should use the result of this method to attach listeners for bubbling events
* or the |blurListener| parameter to register a "blur" event listener on the |element|
* (since the "blur" event does not bubble.)
*
* @param {!Element} element
* @param {function(!Event)} blurListener
* @return {!Element}
*/
attachAndStartEditing(element, blurListener) {
const proxyElement = this._attachInternal(element);
this._startEditing(blurListener);
return proxyElement;
}
/**
* @param {!Element} element
* @return {!Element}
*/
_attachInternal(element) {
if (this._proxyElement) {
throw 'Cannot attach an attached TextPrompt';
}
this._element = element;
this._boundOnKeyDown = this.onKeyDown.bind(this);
this._boundOnInput = this.onInput.bind(this);
this._boundOnMouseWheel = this.onMouseWheel.bind(this);
this._boundClearAutocomplete = this.clearAutocomplete.bind(this);
this._proxyElement = element.ownerDocument.createElement('span');
UI.appendStyle(this._proxyElement, 'ui/textPrompt.css');
this._contentElement = this._proxyElement.createChild('div', 'text-prompt-root');
this._proxyElement.style.display = this._proxyElementDisplay;
element.parentElement.insertBefore(this._proxyElement, element);
this._contentElement.appendChild(element);
this._element.classList.add('text-prompt');
UI.ARIAUtils.markAsTextBox(this._element);
this._element.setAttribute('contenteditable', 'plaintext-only');
this._element.addEventListener('keydown', this._boundOnKeyDown, false);
this._element.addEventListener('input', this._boundOnInput, false);
this._element.addEventListener('mousewheel', this._boundOnMouseWheel, false);
this._element.addEventListener('selectstart', this._boundClearAutocomplete, false);
this._element.addEventListener('blur', this._boundClearAutocomplete, false);
this._suggestBox = new UI.SuggestBox(this, 20);
if (this._title) {
this._proxyElement.title = this._title;
}
return this._proxyElement;
}
detach() {
this._removeFromElement();
this._focusRestorer.restore();
this._proxyElement.parentElement.insertBefore(this._element, this._proxyElement);
this._proxyElement.remove();
delete this._proxyElement;
this._element.classList.remove('text-prompt');
this._element.removeAttribute('contenteditable');
this._element.removeAttribute('role');
}
/**
* @return {string}
*/
textWithCurrentSuggestion() {
const text = this.text();
if (!this._queryRange || !this._currentSuggestion) {
return text;
}
const suggestion = this._currentSuggestion.text;
return text.substring(0, this._queryRange.startColumn) + suggestion + text.substring(this._queryRange.endColumn);
}
/**
* @return {string}
*/
text() {
let text = this._element.textContent;
if (this._ghostTextElement.parentNode) {
const addition = this._ghostTextElement.textContent;
text = text.substring(0, text.length - addition.length);
}
return text;
}
/**
* @param {string} text
*/
setText(text) {
this.clearAutocomplete();
this._element.textContent = text;
this._previousText = this.text();
if (this._element.hasFocus()) {
this.moveCaretToEndOfPrompt();
this._element.scrollIntoView();
}
}
focus() {
this._element.focus();
}
/**
* @return {string}
*/
title() {
return this._title;
}
/**
* @param {string} title
*/
setTitle(title) {
this._title = title;
if (this._proxyElement) {
this._proxyElement.title = title;
}
}
/**
* @param {string} placeholder
* @param {string=} ariaPlaceholder
*/
setPlaceholder(placeholder, ariaPlaceholder) {
if (placeholder) {
this._element.setAttribute('data-placeholder', placeholder);
// TODO(https://github.com/nvaccess/nvda/issues/10164): Remove ariaPlaceholder once the NVDA bug is fixed
// ariaPlaceholder and placeholder may differ, like in case the placeholder contains a '?'
UI.ARIAUtils.setPlaceholder(this._element, ariaPlaceholder || placeholder);
} else {
this._element.removeAttribute('data-placeholder');
UI.ARIAUtils.setPlaceholder(this._element, null);
}
}
/**
* @param {boolean} enabled
*/
setEnabled(enabled) {
if (enabled) {
this._element.setAttribute('contenteditable', 'plaintext-only');
} else {
this._element.removeAttribute('contenteditable');
}
this._element.classList.toggle('disabled', !enabled);
}
_removeFromElement() {
this.clearAutocomplete();
this._element.removeEventListener('keydown', this._boundOnKeyDown, false);
this._element.removeEventListener('input', this._boundOnInput, false);
this._element.removeEventListener('selectstart', this._boundClearAutocomplete, false);
this._element.removeEventListener('blur', this._boundClearAutocomplete, false);
if (this._isEditing) {
this._stopEditing();
}
if (this._suggestBox) {
this._suggestBox.hide();
}
}
/**
* @param {function(!Event)=} blurListener
*/
_startEditing(blurListener) {
this._isEditing = true;
this._contentElement.classList.add('text-prompt-editing');
if (blurListener) {
this._blurListener = blurListener;
this._element.addEventListener('blur', this._blurListener, false);
}
this._oldTabIndex = this._element.tabIndex;
if (this._element.tabIndex < 0) {
this._element.tabIndex = 0;
}
this._focusRestorer = new UI.ElementFocusRestorer(this._element);
if (!this.text()) {
this.autoCompleteSoon();
}
}
_stopEditing() {
this._element.tabIndex = this._oldTabIndex;
if (this._blurListener) {
this._element.removeEventListener('blur', this._blurListener, false);
}
this._contentElement.classList.remove('text-prompt-editing');
delete this._isEditing;
}
/**
* @param {!Event} event
*/
onMouseWheel(event) {
// Subclasses can implement.
}
/**
* @param {!Event} event
*/
onKeyDown(event) {
let handled = false;
if (this.isSuggestBoxVisible() && this._suggestBox.keyPressed(event)) {
event.consume(true);
return;
}
switch (event.key) {
case 'Tab':
handled = this.tabKeyPressed(event);
break;
case 'ArrowLeft':
case 'ArrowUp':
case 'PageUp':
case 'Home':
this.clearAutocomplete();
break;
case 'PageDown':
case 'ArrowRight':
case 'ArrowDown':
case 'End':
if (this._isCaretAtEndOfPrompt()) {
handled = this.acceptAutoComplete();
} else {
this.clearAutocomplete();
}
break;
case 'Escape':
if (this.isSuggestBoxVisible()) {
this.clearAutocomplete();
handled = true;
}
break;
case ' ': // Space
if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
this.autoCompleteSoon(true);
handled = true;
}
break;
}
if (isEnterKey(event)) {
event.preventDefault();
}
if (handled) {
event.consume(true);
}
}
/**
* @param {string} key
* @return {boolean}
*/
_acceptSuggestionOnStopCharacters(key) {
if (!this._currentSuggestion || !this._queryRange || key.length !== 1 ||
!this._completionStopCharacters.includes(key)) {
return false;
}
const query = this.text().substring(this._queryRange.startColumn, this._queryRange.endColumn);
if (query && this._currentSuggestion.text.startsWith(query + key)) {
this._queryRange.endColumn += 1;
return this.acceptAutoComplete();
}
return false;
}
/**
* @param {!Event} event
*/
onInput(event) {
const text = this.text();
if (event.data && !this._acceptSuggestionOnStopCharacters(event.data)) {
const hasCommonPrefix = text.startsWith(this._previousText) || this._previousText.startsWith(text);
if (this._queryRange && hasCommonPrefix) {
this._queryRange.endColumn += text.length - this._previousText.length;
}
}
this._refreshGhostText();
this._previousText = text;
this.dispatchEventToListeners(Events.TextChanged);
this.autoCompleteSoon();
}
/**
* @return {boolean}
*/
acceptAutoComplete() {
let result = false;
if (this.isSuggestBoxVisible()) {
result = this._suggestBox.acceptSuggestion();
}
if (!result) {
result = this._acceptSuggestionInternal();
}
return result;
}
clearAutocomplete() {
const beforeText = this.textWithCurrentSuggestion();
if (this.isSuggestBoxVisible()) {
this._suggestBox.hide();
}
this._clearAutocompleteTimeout();
this._queryRange = null;
this._refreshGhostText();
if (beforeText !== this.textWithCurrentSuggestion()) {
this.dispatchEventToListeners(Events.TextChanged);
}
}
_refreshGhostText() {
if (this._currentSuggestion && this._currentSuggestion.hideGhostText) {
this._ghostTextElement.remove();
return;
}
if (this._queryRange && this._currentSuggestion && this._isCaretAtEndOfPrompt() &&
this._currentSuggestion.text.startsWith(this.text().substring(this._queryRange.startColumn))) {
this._ghostTextElement.textContent =
this._currentSuggestion.text.substring(this._queryRange.endColumn - this._queryRange.startColumn);
this._element.appendChild(this._ghostTextElement);
} else {
this._ghostTextElement.remove();
}
}
_clearAutocompleteTimeout() {
if (this._completeTimeout) {
clearTimeout(this._completeTimeout);
delete this._completeTimeout;
}
this._completionRequestId++;
}
/**
* @param {boolean=} force
*/
autoCompleteSoon(force) {
const immediately = this.isSuggestBoxVisible() || force;
if (!this._completeTimeout) {
this._completeTimeout =
setTimeout(this.complete.bind(this, force), immediately ? 0 : this._autocompletionTimeout);
}
}
/**
* @param {boolean=} force
*/
async complete(force) {
this._clearAutocompleteTimeout();
const selection = this._element.getComponentSelection();
const selectionRange = selection && selection.rangeCount ? selection.getRangeAt(0) : null;
if (!selectionRange) {
return;
}
let shouldExit;
if (!force && !this._isCaretAtEndOfPrompt() && !this.isSuggestBoxVisible()) {
shouldExit = true;
} else if (!selection.isCollapsed) {
shouldExit = true;
}
if (shouldExit) {
this.clearAutocomplete();
return;
}
const wordQueryRange = selectionRange.startContainer.rangeOfWord(
selectionRange.startOffset, this._completionStopCharacters, this._element, 'backward');
const expressionRange = wordQueryRange.cloneRange();
expressionRange.collapse(true);
expressionRange.setStartBefore(this._element);
const completionRequestId = ++this._completionRequestId;
const completions = await this._loadCompletions(expressionRange.toString(), wordQueryRange.toString(), !!force);
this._completionsReady(completionRequestId, selection, wordQueryRange, !!force, completions);
}
disableDefaultSuggestionForEmptyInput() {
this._disableDefaultSuggestionForEmptyInput = true;
}
/**
* @param {!Selection} selection
* @param {!Range} textRange
*/
_boxForAnchorAtStart(selection, textRange) {
const rangeCopy = selection.getRangeAt(0).cloneRange();
const anchorElement = createElement('span');
anchorElement.textContent = '\u200B';
textRange.insertNode(anchorElement);
const box = anchorElement.boxInWindow(window);
anchorElement.remove();
selection.removeAllRanges();
selection.addRange(rangeCopy);
return box;
}
/**
* @return {?Range}
* @suppressGlobalPropertiesCheck
*/
_createRange() {
return document.createRange();
}
/**
* @param {string} query
* @return {!UI.SuggestBox.Suggestions}
*/
additionalCompletions(query) {
return [];
}
/**
* @param {number} completionRequestId
* @param {!Selection} selection
* @param {!Range} originalWordQueryRange
* @param {boolean} force
* @param {!UI.SuggestBox.Suggestions} completions
*/
_completionsReady(completionRequestId, selection, originalWordQueryRange, force, completions) {
if (this._completionRequestId !== completionRequestId) {
return;
}
const query = originalWordQueryRange.toString();
// Filter out dupes.
const store = new Set();
completions = completions.filter(item => !store.has(item.text) && !!store.add(item.text));
if (query || force) {
if (query) {
completions = completions.concat(this.additionalCompletions(query));
} else {
completions = this.additionalCompletions(query).concat(completions);
}
}
if (!completions.length) {
this.clearAutocomplete();
return;
}
const selectionRange = selection.getRangeAt(0);
const fullWordRange = this._createRange();
fullWordRange.setStart(originalWordQueryRange.startContainer, originalWordQueryRange.startOffset);
fullWordRange.setEnd(selectionRange.endContainer, selectionRange.endOffset);
if (query + selectionRange.toString() !== fullWordRange.toString()) {
return;
}
const beforeRange = this._createRange();
beforeRange.setStart(this._element, 0);
beforeRange.setEnd(fullWordRange.startContainer, fullWordRange.startOffset);
this._queryRange = new TextUtils.TextRange(
0, beforeRange.toString().length, 0, beforeRange.toString().length + fullWordRange.toString().length);
const shouldSelect = !this._disableDefaultSuggestionForEmptyInput || !!this.text();
if (this._suggestBox) {
this._suggestBox.updateSuggestions(
this._boxForAnchorAtStart(selection, fullWordRange), completions, shouldSelect, !this._isCaretAtEndOfPrompt(),
this.text());
}
}
/**
* @override
* @param {?UI.SuggestBox.Suggestion} suggestion
* @param {boolean=} isIntermediateSuggestion
*/
applySuggestion(suggestion, isIntermediateSuggestion) {
this._currentSuggestion = suggestion;
this._refreshGhostText();
if (isIntermediateSuggestion) {
this.dispatchEventToListeners(Events.TextChanged);
}
}
/**
* @override
*/
acceptSuggestion() {
this._acceptSuggestionInternal();
}
/**
* @return {boolean}
*/
_acceptSuggestionInternal() {
if (!this._queryRange) {
return false;
}
const suggestionLength = this._currentSuggestion ? this._currentSuggestion.text.length : 0;
const selectionRange = this._currentSuggestion ? this._currentSuggestion.selectionRange : null;
const endColumn = selectionRange ? selectionRange.endColumn : suggestionLength;
const startColumn = selectionRange ? selectionRange.startColumn : suggestionLength;
this._element.textContent = this.textWithCurrentSuggestion();
this.setDOMSelection(this._queryRange.startColumn + startColumn, this._queryRange.startColumn + endColumn);
this.clearAutocomplete();
this.dispatchEventToListeners(Events.TextChanged);
return true;
}
/**
* @param {number} startColumn
* @param {number} endColumn
*/
setDOMSelection(startColumn, endColumn) {
this._element.normalize();
const node = this._element.childNodes[0];
if (!node || node === this._ghostTextElement) {
return;
}
const range = this._createRange();
range.setStart(node, startColumn);
range.setEnd(node, endColumn);
const selection = this._element.getComponentSelection();
selection.removeAllRanges();
selection.addRange(range);
}
/**
* @protected
* @return {boolean}
*/
isSuggestBoxVisible() {
return this._suggestBox && this._suggestBox.visible();
}
/**
* @return {boolean}
*/
isCaretInsidePrompt() {
const selection = this._element.getComponentSelection();
// @see crbug.com/602541
const selectionRange = selection && selection.rangeCount ? selection.getRangeAt(0) : null;
if (!selectionRange || !selection.isCollapsed) {
return false;
}
return selectionRange.startContainer.isSelfOrDescendant(this._element);
}
/**
* @return {boolean}
*/
_isCaretAtEndOfPrompt() {
const selection = this._element.getComponentSelection();
const selectionRange = selection && selection.rangeCount ? selection.getRangeAt(0) : null;
if (!selectionRange || !selection.isCollapsed) {
return false;
}
let node = selectionRange.startContainer;
if (!node.isSelfOrDescendant(this._element)) {
return false;
}
if (this._ghostTextElement.isAncestor(node)) {
return true;
}
if (node.nodeType === Node.TEXT_NODE && selectionRange.startOffset < node.nodeValue.length) {
return false;
}
let foundNextText = false;
while (node) {
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.length) {
if (foundNextText && !this._ghostTextElement.isAncestor(node)) {
return false;
}
foundNextText = true;
}
node = node.traverseNextNode(this._element);
}
return true;
}
moveCaretToEndOfPrompt() {
const selection = this._element.getComponentSelection();
const selectionRange = this._createRange();
let container = this._element;
while (container.childNodes.length) {
container = container.lastChild;
}
const offset = container.nodeType === Node.TEXT_NODE ? container.textContent.length : 0;
selectionRange.setStart(container, offset);
selectionRange.setEnd(container, offset);
selection.removeAllRanges();
selection.addRange(selectionRange);
}
/**
* @param {!Event} event
* @return {boolean}
*/
tabKeyPressed(event) {
return this.acceptAutoComplete();
}
/**
* @return {?Element}
*/
proxyElementForTests() {
return this._proxyElement || null;
}
}
const DefaultAutocompletionTimeout = 250;
/** @enum {symbol} */
export const Events = {
TextChanged: Symbol('TextChanged')
};
/* Legacy exported object*/
self.UI = self.UI || {};
/* Legacy exported object*/
UI = UI || {};
/** @constructor */
UI.TextPrompt = TextPrompt;
/** @enum {symbol} */
UI.TextPrompt.Events = Events;