Web Inspector: Styles Redesign: hook up autocompletion to property names and values
authornvasilyev@apple.com <nvasilyev@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 13 Oct 2017 17:14:22 +0000 (17:14 +0000)
committernvasilyev@apple.com <nvasilyev@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 13 Oct 2017 17:14:22 +0000 (17:14 +0000)
https://bugs.webkit.org/show_bug.cgi?id=177313
<rdar://problem/34577057>

Reviewed by Joseph Pecoraro.

- Arrow Right accept the current completion item and places the text caret after it.
- Arrow Left hides the completion popover.
- Arrow Up selects the previous completion item.
- Arrow Down selects the next completion item.
- Enter and Tab accept the current completion item and navigate to the next focusable item.
- Escape hides the completion popover, if there is one.

* UserInterface/Views/CompletionSuggestionsView.js:
(WI.CompletionSuggestionsView):
(WI.CompletionSuggestionsView.prototype._mouseDown):
Add a preventBlur option so clicking on an completion item doesn't change the focus and
doesn't cause "blur" event on the target text field.

* UserInterface/Views/SpreadsheetCSSStyleDeclarationEditor.css:
(.spreadsheet-style-declaration-editor .completion-hint):
* UserInterface/Views/SpreadsheetCSSStyleDeclarationEditor.js:
(WI.SpreadsheetCSSStyleDeclarationEditor):
(WI.SpreadsheetCSSStyleDeclarationEditor.prototype.layout):
(WI.SpreadsheetCSSStyleDeclarationEditor.prototype.detached):
Call detached on every SpreadsheetTextField to hide CompletionSuggestionsView once
SpreadsheetCSSStyleDeclarationEditor is removed from the DOM.

(WI.SpreadsheetCSSStyleDeclarationEditor.prototype._addBlankProperty):
Remove index argument since it is no longer used.

* UserInterface/Views/SpreadsheetStyleProperty.js:
(WI.SpreadsheetStyleProperty):
(WI.SpreadsheetStyleProperty.prototype.detached):
(WI.SpreadsheetStyleProperty.prototype._remove):
(WI.SpreadsheetStyleProperty.prototype._update):
(WI.SpreadsheetStyleProperty.prototype._nameCompletionDataProvider):
(WI.SpreadsheetStyleProperty.prototype._valueCompletionDataProvider):
Add an extra parameter to SpreadsheetTextField to pass a completion data provider.

* UserInterface/Views/SpreadsheetTextField.js:
(WI.SpreadsheetTextField):
(WI.SpreadsheetTextField.prototype.get suggestionHint):
(WI.SpreadsheetTextField.prototype.set suggestionHint):
(WI.SpreadsheetTextField.prototype.startEditing):
(WI.SpreadsheetTextField.prototype.stopEditing):
(WI.SpreadsheetTextField.prototype.detached):
(WI.SpreadsheetTextField.prototype.completionSuggestionsSelectedCompletion):
(WI.SpreadsheetTextField.prototype.completionSuggestionsClickedCompletion):
(WI.SpreadsheetTextField.prototype._getPrefix):
(WI.SpreadsheetTextField.prototype._handleBlur):
(WI.SpreadsheetTextField.prototype._handleKeyDown):
(WI.SpreadsheetTextField.prototype._handleKeyDownForSuggestionView):
(WI.SpreadsheetTextField.prototype._handleInput):
(WI.SpreadsheetTextField.prototype._updateCompletions):
(WI.SpreadsheetTextField.prototype._getCaretRect):
(WI.SpreadsheetTextField.prototype._getCompletionPrefix):
(WI.SpreadsheetTextField.prototype._applyCompletionHint):
(WI.SpreadsheetTextField.prototype._hideCompletions):
Provide text completion based on the existing CompletionSuggestionsView when completionProvider is passed to SpreadsheetTextField.

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@223283 268f45cc-cd09-0410-ab3c-d52691b4dbfc

Source/WebInspectorUI/ChangeLog
Source/WebInspectorUI/UserInterface/Views/CompletionSuggestionsView.js
Source/WebInspectorUI/UserInterface/Views/SpreadsheetCSSStyleDeclarationEditor.css
Source/WebInspectorUI/UserInterface/Views/SpreadsheetCSSStyleDeclarationEditor.js
Source/WebInspectorUI/UserInterface/Views/SpreadsheetStyleProperty.js
Source/WebInspectorUI/UserInterface/Views/SpreadsheetTextField.js

index 9306132..a9c4b52 100644 (file)
@@ -1,3 +1,66 @@
+2017-10-13  Nikita Vasilyev  <nvasilyev@apple.com>
+
+        Web Inspector: Styles Redesign: hook up autocompletion to property names and values
+        https://bugs.webkit.org/show_bug.cgi?id=177313
+        <rdar://problem/34577057>
+
+        Reviewed by Joseph Pecoraro.
+
+        - Arrow Right accept the current completion item and places the text caret after it.
+        - Arrow Left hides the completion popover.
+        - Arrow Up selects the previous completion item.
+        - Arrow Down selects the next completion item.
+        - Enter and Tab accept the current completion item and navigate to the next focusable item.
+        - Escape hides the completion popover, if there is one.
+
+        * UserInterface/Views/CompletionSuggestionsView.js:
+        (WI.CompletionSuggestionsView):
+        (WI.CompletionSuggestionsView.prototype._mouseDown):
+        Add a preventBlur option so clicking on an completion item doesn't change the focus and
+        doesn't cause "blur" event on the target text field.
+
+        * UserInterface/Views/SpreadsheetCSSStyleDeclarationEditor.css:
+        (.spreadsheet-style-declaration-editor .completion-hint):
+        * UserInterface/Views/SpreadsheetCSSStyleDeclarationEditor.js:
+        (WI.SpreadsheetCSSStyleDeclarationEditor):
+        (WI.SpreadsheetCSSStyleDeclarationEditor.prototype.layout):
+        (WI.SpreadsheetCSSStyleDeclarationEditor.prototype.detached):
+        Call detached on every SpreadsheetTextField to hide CompletionSuggestionsView once
+        SpreadsheetCSSStyleDeclarationEditor is removed from the DOM.
+
+        (WI.SpreadsheetCSSStyleDeclarationEditor.prototype._addBlankProperty):
+        Remove index argument since it is no longer used.
+
+        * UserInterface/Views/SpreadsheetStyleProperty.js:
+        (WI.SpreadsheetStyleProperty):
+        (WI.SpreadsheetStyleProperty.prototype.detached):
+        (WI.SpreadsheetStyleProperty.prototype._remove):
+        (WI.SpreadsheetStyleProperty.prototype._update):
+        (WI.SpreadsheetStyleProperty.prototype._nameCompletionDataProvider):
+        (WI.SpreadsheetStyleProperty.prototype._valueCompletionDataProvider):
+        Add an extra parameter to SpreadsheetTextField to pass a completion data provider.
+
+        * UserInterface/Views/SpreadsheetTextField.js:
+        (WI.SpreadsheetTextField):
+        (WI.SpreadsheetTextField.prototype.get suggestionHint):
+        (WI.SpreadsheetTextField.prototype.set suggestionHint):
+        (WI.SpreadsheetTextField.prototype.startEditing):
+        (WI.SpreadsheetTextField.prototype.stopEditing):
+        (WI.SpreadsheetTextField.prototype.detached):
+        (WI.SpreadsheetTextField.prototype.completionSuggestionsSelectedCompletion):
+        (WI.SpreadsheetTextField.prototype.completionSuggestionsClickedCompletion):
+        (WI.SpreadsheetTextField.prototype._getPrefix):
+        (WI.SpreadsheetTextField.prototype._handleBlur):
+        (WI.SpreadsheetTextField.prototype._handleKeyDown):
+        (WI.SpreadsheetTextField.prototype._handleKeyDownForSuggestionView):
+        (WI.SpreadsheetTextField.prototype._handleInput):
+        (WI.SpreadsheetTextField.prototype._updateCompletions):
+        (WI.SpreadsheetTextField.prototype._getCaretRect):
+        (WI.SpreadsheetTextField.prototype._getCompletionPrefix):
+        (WI.SpreadsheetTextField.prototype._applyCompletionHint):
+        (WI.SpreadsheetTextField.prototype._hideCompletions):
+        Provide text completion based on the existing CompletionSuggestionsView when completionProvider is passed to SpreadsheetTextField.
+
 2017-10-12  Joseph Pecoraro  <pecoraro@apple.com>
 
         Web Inspector: Switch Clear navigation item back to the Trash icon (Console, Timelines, Network)
index 631025e..ffb58ae 100644 (file)
 
 WI.CompletionSuggestionsView = class CompletionSuggestionsView extends WI.Object
 {
-    constructor(delegate)
+    constructor(delegate, {preventBlur} = {})
     {
         super();
 
         this._delegate = delegate || null;
+        this._preventBlur = preventBlur || false;
 
         this._selectedIndex = NaN;
 
@@ -197,6 +198,10 @@ WI.CompletionSuggestionsView = class CompletionSuggestionsView extends WI.Object
     {
         if (event.button !== 0)
             return;
+
+        if (this._preventBlur)
+            event.preventDefault();
+
         this._mouseIsDown = true;
     }
 
index 5557f21..7717fa0 100644 (file)
     padding-bottom: 0 !important;
 }
 
+.spreadsheet-style-declaration-editor .value.editing {
+    display: inline-block;
+    margin-right: 3px;
+}
+
 .spreadsheet-style-declaration-editor.no-properties {
     display: none;
 }
@@ -79,3 +84,7 @@
 .spreadsheet-style-declaration-editor .property.not-inherited {
     opacity: 0.5;
 }
+
+.spreadsheet-style-declaration-editor .completion-hint {
+    color: hsl(0, 0%, 50%) !important;
+}
index cf91644..f8fd351 100644 (file)
@@ -33,6 +33,7 @@ WI.SpreadsheetCSSStyleDeclarationEditor = class SpreadsheetCSSStyleDeclarationEd
 
         this._delegate = delegate;
         this.style = style;
+        this._propertyViews = [];
     }
 
     // Public
@@ -49,12 +50,18 @@ WI.SpreadsheetCSSStyleDeclarationEditor = class SpreadsheetCSSStyleDeclarationEd
         this._propertyViews = [];
         for (let index = 0; index < properties.length; index++) {
             let property = properties[index];
-            let propertyView = new WI.SpreadsheetStyleProperty(this, property, index);
+            let propertyView = new WI.SpreadsheetStyleProperty(this, property);
             this.element.append(propertyView.element);
             this._propertyViews.push(propertyView);
         }
     }
 
+    detached()
+    {
+        for (let propertyView of this._propertyViews)
+            propertyView.detached();
+    }
+
     get style()
     {
         return this._style;
@@ -155,7 +162,7 @@ WI.SpreadsheetCSSStyleDeclarationEditor = class SpreadsheetCSSStyleDeclarationEd
     {
         let blankProperty = this._style.newBlankProperty(afterIndex);
         const newlyAdded = true;
-        let propertyView = new WI.SpreadsheetStyleProperty(this, blankProperty, blankProperty.index, newlyAdded);
+        let propertyView = new WI.SpreadsheetStyleProperty(this, blankProperty, newlyAdded);
         this.element.append(propertyView.element);
         this._propertyViews.push(propertyView);
         propertyView.nameTextField.startEditing();
index f58c6c0..485fbc4 100644 (file)
 
 WI.SpreadsheetStyleProperty = class SpreadsheetStyleProperty extends WI.Object
 {
-    constructor(delegate, property, index, newlyAdded)
+    constructor(delegate, property, newlyAdded = false)
     {
         super();
 
+        console.assert(property instanceof WI.CSSProperty);
+
         this._delegate = delegate || null;
         this._property = property;
-        this._newlyAdded = newlyAdded || false;
+        this._newlyAdded = newlyAdded;
         this._element = document.createElement("div");
 
         this._nameElement = null;
@@ -50,12 +52,22 @@ WI.SpreadsheetStyleProperty = class SpreadsheetStyleProperty extends WI.Object
     get nameTextField() { return this._nameTextField; }
     get valueTextField() { return this._valueTextField; }
 
+    detached()
+    {
+        if (this._nameTextField)
+            this._nameTextField.detached();
+
+        if (this._valueTextField)
+            this._valueTextField.detached();
+    }
+
     // Private
 
     _remove()
     {
         this.element.remove();
         this._property.remove();
+        this.detached();
 
         if (this._delegate && typeof this._delegate.spreadsheetStylePropertyRemoved === "function")
             this._delegate.spreadsheetStylePropertyRemoved(this);
@@ -134,10 +146,10 @@ WI.SpreadsheetStyleProperty = class SpreadsheetStyleProperty extends WI.Object
 
         if (this._property.editable && this._property.enabled) {
             this._nameElement.tabIndex = 0;
-            this._nameTextField = new WI.SpreadsheetTextField(this, this._nameElement);
+            this._nameTextField = new WI.SpreadsheetTextField(this, this._nameElement, this._nameCompletionDataProvider.bind(this));
 
             this._valueElement.tabIndex = 0;
-            this._valueTextField = new WI.SpreadsheetTextField(this, this._valueElement);
+            this._valueTextField = new WI.SpreadsheetTextField(this, this._valueElement, this._valueCompletionDataProvider.bind(this));
         }
 
         this.element.append(";");
@@ -209,6 +221,16 @@ WI.SpreadsheetStyleProperty = class SpreadsheetStyleProperty extends WI.Object
     {
         this._property.rawValue = this._valueElement.textContent.trim();
     }
+
+    _nameCompletionDataProvider(prefix)
+    {
+        return WI.CSSCompletions.cssNameCompletions.startsWith(prefix);
+    }
+
+    _valueCompletionDataProvider(prefix)
+    {
+        return WI.CSSKeywordCompletions.forProperty(this._property.name).startsWith(prefix);
+    }
 };
 
 WI.SpreadsheetStyleProperty.CommitCoalesceDelay = 250;
index 790e094..b96bf54 100644 (file)
 
 WI.SpreadsheetTextField = class SpreadsheetTextField
 {
-    constructor(delegate, element)
+    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));
@@ -49,6 +58,22 @@ WI.SpreadsheetTextField = class SpreadsheetTextField
     get value() { return this._element.textContent; }
     set value(value) { this._element.textContent = value; }
 
+    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)
@@ -67,6 +92,8 @@ WI.SpreadsheetTextField = class SpreadsheetTextField
 
         this._element.focus();
         this._selectText();
+
+        this._updateCompletions();
     }
 
     stopEditing()
@@ -78,6 +105,59 @@ WI.SpreadsheetTextField = class SpreadsheetTextField
         this._startEditingValue = "";
         this._element.classList.remove("editing");
         this._element.contentEditable = false;
+
+        this._hideCompletions();
+    }
+
+    detached()
+    {
+        this._hideCompletions();
+        this._element.remove();
+    }
+
+    // CompletionSuggestionsView delegate
+
+    completionSuggestionsSelectedCompletion(suggestionsView, selectedText = "")
+    {
+        let prefix = this._getPrefix();
+        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._getPrefix();
+        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._hideCompletions();
+
+        if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
+            this._delegate.spreadsheetTextFieldDidChange(this);
     }
 
     // Private
@@ -98,6 +178,12 @@ WI.SpreadsheetTextField = class SpreadsheetTextField
         }
     }
 
+    _getPrefix()
+    {
+        let value = this._element.textContent;
+        return value.slice(0, value.length - this.suggestionHint.length);
+    }
+
     _handleFocus(event)
     {
         this.startEditing();
@@ -108,6 +194,9 @@ WI.SpreadsheetTextField = class SpreadsheetTextField
         if (!this._editing)
             return;
 
+        this._applyCompletionHint();
+        this._hideCompletions();
+
         this._delegate.spreadsheetTextFieldDidBlur(this);
         this.stopEditing();
     }
@@ -117,15 +206,22 @@ WI.SpreadsheetTextField = class SpreadsheetTextField
         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.stopEditing();
+            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;
         }
 
@@ -135,12 +231,158 @@ WI.SpreadsheetTextField = class SpreadsheetTextField
         }
     }
 
+    _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._getPrefix().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._hideCompletions();
+
+            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._hideCompletions();
+
+            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._getPrefix();
+        let completionPrefix = this._getCompletionPrefix(prefix);
+        let completions = this._completionProvider(completionPrefix);
+
+        if (!completions.length) {
+            this._hideCompletions();
+            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._hideCompletions();
+            return;
+        }
+
+        console.assert(this._element.parentNode, "_updateCompletions got called after SpreadsheetTextField was removed from the DOM");
+        if (!this._element.parentNode) {
+            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 caretRect = this._getCaretRect(prefix, completionPrefix);
+            this._suggestionsView.show(caretRect);
+        }
+
+        // Select first item and call completionSuggestionsSelectedCompletion.
+        this._suggestionsView.selectedIndex = NaN;
+        this._suggestionsView.selectNext();
+
+        if (!completionPrefix)
+            this.suggestionHint = "";
+    }
+
+    _getCaretRect(prefix, completionPrefix)
+    {
+        let startOffset = prefix.length - completionPrefix.length;
+        let selection = window.getSelection();
+
+        if (startOffset > 0 && selection.rangeCount) {
+            let range = selection.getRangeAt(0).cloneRange();
+            range.setStart(range.startContainer, startOffset);
+            let clientRect = range.getBoundingClientRect();
+            return WI.Rect.rectFromClientRect(clientRect);
+        }
+
+        let clientRect = this._element.getBoundingClientRect();
+        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;
+    }
+
+    _hideCompletions()
+    {
+        if (!this._completionProvider)
+            return;
+
+        this._suggestionsView.hide();
+        this.suggestionHint = "";
+    }
 };