| /* |
| * Copyright (C) 2017 Apple 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. |
| * |
| * THIS SOFTWARE IS PROVIDED BY APPLE INC. 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 INC. 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. |
| */ |
| |
| WI.SpreadsheetTextField = class SpreadsheetTextField |
| { |
| constructor(delegate, element, completionProvider) |
| { |
| this._delegate = delegate; |
| this._element = element; |
| |
| this._completionProvider = completionProvider || null; |
| if (this._completionProvider) { |
| this._suggestionHintElement = document.createElement("span"); |
| this._suggestionHintElement.contentEditable = false; |
| this._suggestionHintElement.classList.add("completion-hint"); |
| this._suggestionsView = new WI.CompletionSuggestionsView(this, {preventBlur: true}); |
| } |
| |
| this._element.classList.add("spreadsheet-text-field"); |
| |
| this._element.addEventListener("focus", this._handleFocus.bind(this)); |
| this._element.addEventListener("blur", this._handleBlur.bind(this)); |
| this._element.addEventListener("keydown", this._handleKeyDown.bind(this)); |
| this._element.addEventListener("input", this._handleInput.bind(this)); |
| |
| this._editing = false; |
| this._valueBeforeEditing = ""; |
| } |
| |
| // Public |
| |
| get element() { return this._element; } |
| |
| get editing() { return this._editing; } |
| |
| get value() { return this._element.textContent; } |
| set value(value) { this._element.textContent = value; } |
| |
| valueWithoutSuggestion() |
| { |
| let value = this._element.textContent; |
| return value.slice(0, value.length - this.suggestionHint.length); |
| } |
| |
| get suggestionHint() |
| { |
| return this._suggestionHintElement.textContent; |
| } |
| |
| set suggestionHint(value) |
| { |
| this._suggestionHintElement.textContent = value; |
| |
| if (value) { |
| if (this._suggestionHintElement.parentElement !== this._element) |
| this._element.append(this._suggestionHintElement); |
| } else |
| this._suggestionHintElement.remove(); |
| } |
| |
| startEditing() |
| { |
| if (this._editing) |
| return; |
| |
| if (this._delegate && typeof this._delegate.spreadsheetTextFieldWillStartEditing === "function") |
| this._delegate.spreadsheetTextFieldWillStartEditing(this); |
| |
| this._editing = true; |
| this._valueBeforeEditing = this.value; |
| |
| this._element.classList.add("editing"); |
| this._element.contentEditable = "plaintext-only"; |
| this._element.spellcheck = false; |
| this._element.scrollIntoViewIfNeeded(false); |
| |
| this._element.focus(); |
| this._selectText(); |
| |
| this._updateCompletions(); |
| } |
| |
| stopEditing() |
| { |
| if (!this._editing) |
| return; |
| |
| this._editing = false; |
| this._valueBeforeEditing = ""; |
| this._element.classList.remove("editing"); |
| this._element.contentEditable = false; |
| |
| this.discardCompletion(); |
| } |
| |
| discardCompletion() |
| { |
| if (!this._completionProvider) |
| return; |
| |
| this._suggestionsView.hide(); |
| |
| let hadSuggestionHint = !!this.suggestionHint; |
| this.suggestionHint = ""; |
| if (hadSuggestionHint && this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function") |
| this._delegate.spreadsheetTextFieldDidChange(this); |
| } |
| |
| detached() |
| { |
| this.discardCompletion(); |
| this._element.remove(); |
| } |
| |
| // CompletionSuggestionsView delegate |
| |
| completionSuggestionsSelectedCompletion(suggestionsView, selectedText = "") |
| { |
| let prefix = this.valueWithoutSuggestion(); |
| let completionPrefix = this._getCompletionPrefix(prefix); |
| |
| this.suggestionHint = selectedText.slice(completionPrefix.length); |
| |
| if (this._suggestionHintElement.parentElement !== this._element) |
| this._element.append(this._suggestionHintElement); |
| |
| if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function") |
| this._delegate.spreadsheetTextFieldDidChange(this); |
| } |
| |
| completionSuggestionsClickedCompletion(suggestionsView, selectedText) |
| { |
| // Consider the following example: |
| // |
| // border: 1px solid ro| |
| // rosybrown |
| // royalblue |
| // |
| // Clicking on "rosybrown" should replace "ro" with "rosybrown". |
| // |
| // prefix: 1px solid ro |
| // completionPrefix: ro |
| // newPrefix: 1px solid |
| // selectedText: rosybrown |
| let prefix = this.valueWithoutSuggestion(); |
| let completionPrefix = this._getCompletionPrefix(prefix); |
| let newPrefix = prefix.slice(0, -completionPrefix.length); |
| |
| this._element.textContent = newPrefix + selectedText; |
| |
| // Place text caret at the end. |
| window.getSelection().setBaseAndExtent(this._element, selectedText.length, this._element, selectedText.length); |
| |
| this.discardCompletion(); |
| |
| if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function") |
| this._delegate.spreadsheetTextFieldDidChange(this); |
| } |
| |
| // Private |
| |
| _selectText() |
| { |
| window.getSelection().selectAllChildren(this._element); |
| } |
| |
| _discardChange() |
| { |
| if (this._valueBeforeEditing !== this.value) { |
| this.value = this._valueBeforeEditing; |
| this._selectText(); |
| |
| if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function") |
| this._delegate.spreadsheetTextFieldDidChange(this); |
| } |
| } |
| |
| _handleFocus(event) |
| { |
| this.startEditing(); |
| } |
| |
| _handleBlur(event) |
| { |
| if (!this._editing) |
| return; |
| |
| this._applyCompletionHint(); |
| this.discardCompletion(); |
| |
| this._delegate.spreadsheetTextFieldDidBlur(this, event); |
| this.stopEditing(); |
| } |
| |
| _handleKeyDown(event) |
| { |
| if (!this._editing) |
| return; |
| |
| if (this._suggestionsView) { |
| let consumed = this._handleKeyDownForSuggestionView(event); |
| if (consumed) |
| return; |
| } |
| |
| if (event.key === "Enter" || event.key === "Tab") { |
| event.stop(); |
| this._applyCompletionHint(); |
| |
| let direction = (event.shiftKey && event.key === "Tab") ? "backward" : "forward"; |
| |
| if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidCommit === "function") |
| this._delegate.spreadsheetTextFieldDidCommit(this, {direction}); |
| |
| this.stopEditing(); |
| return; |
| } |
| |
| if (event.key === "ArrowUp" || event.key === "ArrowDown") { |
| let delta = 1; |
| if (event.metaKey) |
| delta = 100; |
| else if (event.shiftKey) |
| delta = 10; |
| else if (event.altKey) |
| delta = 0.1; |
| |
| if (event.key === "ArrowDown") |
| delta = -delta; |
| |
| let didModify = WI.incrementElementValue(this._element, delta); |
| if (!didModify) |
| return; |
| |
| event.stop(); |
| |
| if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function") |
| this._delegate.spreadsheetTextFieldDidChange(this); |
| } |
| |
| if (event.key === "Backspace") { |
| if (!this.value) { |
| event.stop(); |
| this.stopEditing(); |
| |
| if (this._delegate && this._delegate.spreadsheetTextFieldDidBackspace) |
| this._delegate.spreadsheetTextFieldDidBackspace(this); |
| |
| return; |
| } |
| } |
| |
| if (event.key === "Escape") { |
| event.stop(); |
| this._discardChange(); |
| } |
| } |
| |
| _handleKeyDownForSuggestionView(event) |
| { |
| if ((event.key === "ArrowDown" || event.key === "ArrowUp") && this._suggestionsView.visible) { |
| event.stop(); |
| |
| if (event.key === "ArrowDown") |
| this._suggestionsView.selectNext(); |
| else |
| this._suggestionsView.selectPrevious(); |
| |
| if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function") |
| this._delegate.spreadsheetTextFieldDidChange(this); |
| |
| return true; |
| } |
| |
| if (event.key === "ArrowRight" && this.suggestionHint) { |
| let selection = window.getSelection(); |
| |
| if (selection.isCollapsed && (selection.focusOffset === this.valueWithoutSuggestion().length || selection.focusNode === this._suggestionHintElement)) { |
| event.stop(); |
| document.execCommand("insertText", false, this.suggestionHint); |
| |
| // When completing "background", don't hide the completion popover. |
| // Continue showing the popover with properties such as "background-color" and "background-image". |
| this._updateCompletions(); |
| |
| if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function") |
| this._delegate.spreadsheetTextFieldDidChange(this); |
| |
| return true; |
| } |
| } |
| |
| if (event.key === "Escape" && this._suggestionsView.visible) { |
| event.stop(); |
| |
| let willChange = !!this.suggestionHint; |
| this.discardCompletion(); |
| |
| if (willChange && this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function") |
| this._delegate.spreadsheetTextFieldDidChange(this); |
| |
| return true; |
| } |
| |
| if (event.key === "ArrowLeft" && (this.suggestionHint || this._suggestionsView.visible)) { |
| this.discardCompletion(); |
| |
| if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function") |
| this._delegate.spreadsheetTextFieldDidChange(this); |
| } |
| |
| return false; |
| } |
| |
| _handleInput(event) |
| { |
| if (!this._editing) |
| return; |
| |
| this._updateCompletions(); |
| |
| if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function") |
| this._delegate.spreadsheetTextFieldDidChange(this); |
| } |
| |
| _updateCompletions() |
| { |
| if (!this._completionProvider) |
| return; |
| |
| let prefix = this.valueWithoutSuggestion(); |
| let completionPrefix = this._getCompletionPrefix(prefix); |
| let completions = this._completionProvider(completionPrefix); |
| |
| if (!completions.length) { |
| this.discardCompletion(); |
| return; |
| } |
| |
| // No need to show the completion popover with only one item that matches the entered value. |
| if (completions.length === 1 && completions[0] === prefix) { |
| this.discardCompletion(); |
| return; |
| } |
| |
| console.assert(this._element.isConnected, "SpreadsheetTextField already removed from the DOM."); |
| if (!this._element.isConnected) { |
| this._suggestionsView.hide(); |
| return; |
| } |
| |
| this._suggestionsView.update(completions); |
| |
| if (completions.length === 1) { |
| // No need to show the completion popover that matches the suggestion hint. |
| this._suggestionsView.hide(); |
| } else { |
| let startOffset = prefix.length - completionPrefix.length; |
| this._suggestionsView.showUntilAnchorMoves(() => { |
| return this._getCaretRect(startOffset); |
| }); |
| } |
| |
| this._suggestionsView.selectedIndex = NaN; |
| if (completionPrefix) { |
| // Select first item and call completionSuggestionsSelectedCompletion. |
| this._suggestionsView.selectNext(); |
| } else |
| this.suggestionHint = ""; |
| } |
| |
| _getCaretRect(startOffset) |
| { |
| let selection = window.getSelection(); |
| |
| let isHidden = (clientRect) => { |
| return clientRect.x === 0 && clientRect.y === 0 |
| }; |
| |
| if (selection.rangeCount) { |
| let range = selection.getRangeAt(0).cloneRange(); |
| range.setStart(range.startContainer, startOffset); |
| let clientRect = range.getBoundingClientRect(); |
| |
| if (!isHidden(clientRect)) { |
| // This happens after deleting value. However, when focusing |
| // on an empty value clientRect is visible. |
| return WI.Rect.rectFromClientRect(clientRect); |
| } |
| } |
| |
| let clientRect = this._element.getBoundingClientRect(); |
| if (isHidden(clientRect)) |
| return null; |
| |
| const leftPadding = parseInt(getComputedStyle(this._element).paddingLeft) || 0; |
| return new WI.Rect(clientRect.left + leftPadding, clientRect.top, clientRect.width, clientRect.height); |
| } |
| |
| _getCompletionPrefix(prefix) |
| { |
| // For "border: 1px so|", we want to suggest "solid" based on "so" prefix. |
| let match = prefix.match(/[a-z0-9()-]+$/i); |
| if (match) |
| return match[0]; |
| |
| return prefix; |
| } |
| |
| _applyCompletionHint() |
| { |
| if (!this._completionProvider || !this.suggestionHint) |
| return; |
| |
| this._element.textContent = this._element.textContent; |
| } |
| }; |