Changeset 285851 in webkit


Ignore:
Timestamp:
Nov 15, 2021 10:01:36 PM (8 months ago)
Author:
Patrick Angle
Message:

Web Inspector: Styles: Autocomplete should support mid-line completions
https://bugs.webkit.org/show_bug.cgi?id=227411

Reviewed by Devin Rousso.

Autocompletion for CSS property values was lacking in the ability to perform mid-line completions, including
within functions, and a lack of support for multi-line CSS property values. This resolves those pain points by
making SpreadsheetTextField multi-line aware and allowing mid-line autocompletion.

  • UserInterface/Views/SpreadsheetStyleProperty.js:

(WI.SpreadsheetStyleProperty.prototype._nameCompletionDataProvider):
(WI.SpreadsheetStyleProperty.prototype._valueCompletionDataProvider):

  • UserInterface/Views/SpreadsheetTextField.js:

(WI.SpreadsheetTextField):
(WI.SpreadsheetTextField.prototype.valueWithoutSuggestion):
Because suggestions can occur anywhere within the value, we need to iterate through each of the nodes and
collect the text of any of them that are not the completion suggestion.

(WI.SpreadsheetTextField.prototype.set suggestionHint):
When removing the suggestion hint element, we should recombine the text nodes we may have split upon insertion
to prevent the text content from becoming endlessly fragmented into multiple text nodes unnecessarily.

(WI.SpreadsheetTextField.prototype.startEditing):
Reset the last known caret position when editing starts so that we don't mistake keys that are already down as
having moved the cursor to its initial position.

(WI.SpreadsheetTextField.prototype.completionSuggestionsSelectedCompletion):
We should only attempt to reattach the suggestion hint element if we have a suggestion hint to show, otherwise
we could end up with a phantom empty suggestion hint that isn't properly placed later.

(WI.SpreadsheetTextField.prototype.completionSuggestionsClickedCompletion):
Updated to use existing completion committing path to reduce duplicated logic.

(WI.SpreadsheetTextField.prototype._handleMouseDown):
When the user clicks inside the text field while suggestions are visible treat that as intent to stop
autocompletion similar to the escape key, since the new cursor location could be anywhere in the text field.
This behavior also matches other code editors like Xcode, where clicking outside a completion popup will dismiss
the completions list.

(WI.SpreadsheetTextField.prototype._handleKeyDown):
Keep track of where the caret was as well as if the the key event was handled by the suggestion view at the time
a key is pressed so that we can later compare the position to that of when the key is released to determine if
completions should be discarded.

(WI.SpreadsheetTextField.prototype._handleKeyDownForSuggestionView):
We can no longer assume that the current selection's offset will match the value's length, as the offset could
be on any line, or the suggestion could be in middle of a line. Because a suggestionHint only contains text when
we are in middle of autocompletion, it alone is an indicator that pressing the right arrow key should commit the
completion. document.execCommand is deprecated, so instead we now use the existing completion committing path
to reduce duplicated logic here.

Note that the left arrow key is still explicitly handled for dismissing autocompletion, as the new mechanism for
dismissing autocompletion won't trigger unless the caret has moved, and the caret will not move if you press the
left arrow key at the start of the text field.

(WI.SpreadsheetTextField.prototype._handleKeyUp):
When a key is released, we should check to see if the caret has moved as a result of the keystroke that we have
not already explicitly handled. If it has moved and was not handled, we dismiss the autocompletion suggestions.

(WI.SpreadsheetTextField.prototype._handleInput):

(WI.SpreadsheetTextField.prototype._updateCompletions):
Provide the current caret position to the completion provided.

(WI.SpreadsheetTextField.prototype._showSuggestionsView):
In order to correctly align the completion list with the current text content, it must be offset from the caret
position excluding the current prefix.

(WI.SpreadsheetTextField.prototype._getCaretPosition):
Added to get the index of the caret in the complete text value, accounting for multi-line values and mid-line
suggestions.

(WI.SpreadsheetTextField.prototype._getCaretRect):

(WI.SpreadsheetTextField.prototype._rangeAtCaretPosition):
Find the range for a caret at the given position. This compliments _getCaretPosition, and allows us to count
back some number of characters and create a range of which we later get the client rectangle.

(WI.SpreadsheetTextField.prototype._applyCompletionHint):
Add optional support for updating the caret position to be at the end of the newly inserted text.

(WI.SpreadsheetTextField.prototype._combineEditorElementChildren):
Added to handle combining the fragmented text nodes (and possibly a suggestion hint element) back into a single
text node while maintaining the current cursor position or optionally moving the cursor to a new location (e.g.
the end of a completion).

(WI.SpreadsheetTextField.prototype._reAttachSuggestionHint):
Now that completions are not guaranteed to be at the end of the value, we may need to split the text node to
insert the suggestion hint node.

Location:
trunk/Source/WebInspectorUI
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • trunk/Source/WebInspectorUI/ChangeLog

    r285839 r285851  
     12021-11-15  Patrick Angle  <pangle@apple.com>
     2
     3        Web Inspector: Styles: Autocomplete should support mid-line completions
     4        https://bugs.webkit.org/show_bug.cgi?id=227411
     5
     6        Reviewed by Devin Rousso.
     7
     8        Autocompletion for CSS property values was lacking in the ability to perform mid-line completions, including
     9        within functions, and a lack of support for multi-line CSS property values. This resolves those pain points by
     10        making SpreadsheetTextField multi-line aware and allowing mid-line autocompletion.
     11
     12        * UserInterface/Views/SpreadsheetStyleProperty.js:
     13        (WI.SpreadsheetStyleProperty.prototype._nameCompletionDataProvider):
     14        (WI.SpreadsheetStyleProperty.prototype._valueCompletionDataProvider):
     15
     16        * UserInterface/Views/SpreadsheetTextField.js:
     17        (WI.SpreadsheetTextField):
     18        (WI.SpreadsheetTextField.prototype.valueWithoutSuggestion):
     19        Because suggestions can occur anywhere within the value, we need to iterate through each of the nodes and
     20        collect the text of any of them that are not the completion suggestion.
     21
     22        (WI.SpreadsheetTextField.prototype.set suggestionHint):
     23        When removing the suggestion hint element, we should recombine the text nodes we may have split upon insertion
     24        to prevent the text content from becoming endlessly fragmented into multiple text nodes unnecessarily.
     25
     26        (WI.SpreadsheetTextField.prototype.startEditing):
     27        Reset the last known caret position when editing starts so that we don't mistake keys that are already down as
     28        having moved the cursor to its initial position.
     29
     30        (WI.SpreadsheetTextField.prototype.completionSuggestionsSelectedCompletion):
     31        We should only attempt to reattach the suggestion hint element if we have a suggestion hint to show, otherwise
     32        we could end up with a phantom empty suggestion hint that isn't properly placed later.
     33
     34        (WI.SpreadsheetTextField.prototype.completionSuggestionsClickedCompletion):
     35        Updated to use existing completion committing path to reduce duplicated logic.
     36
     37        (WI.SpreadsheetTextField.prototype._handleMouseDown):
     38        When the user clicks inside the text field while suggestions are visible treat that as intent to stop
     39        autocompletion similar to the escape key, since the new cursor location could be anywhere in the text field.
     40        This behavior also matches other code editors like Xcode, where clicking outside a completion popup will dismiss
     41        the completions list.
     42
     43        (WI.SpreadsheetTextField.prototype._handleKeyDown):
     44        Keep track of where the caret was as well as if the the key event was handled by the suggestion view at the time
     45        a key is pressed so that we can later compare the position to that of when the key is released to determine if
     46        completions should be discarded.
     47
     48        (WI.SpreadsheetTextField.prototype._handleKeyDownForSuggestionView):
     49        We can no longer assume that the current selection's offset will match the value's length, as the offset could
     50        be on any line, or the suggestion could be in middle of a line. Because a suggestionHint only contains text when
     51        we are in middle of autocompletion, it alone is an indicator that pressing the right arrow key should commit the
     52        completion. `document.execCommand` is deprecated, so instead we now use the existing completion committing path
     53        to reduce duplicated logic here.
     54
     55        Note that the left arrow key is still explicitly handled for dismissing autocompletion, as the new mechanism for
     56        dismissing autocompletion won't trigger unless the caret has moved, and the caret will not move if you press the
     57        left arrow key at the start of the text field.
     58
     59        (WI.SpreadsheetTextField.prototype._handleKeyUp):
     60        When a key is released, we should check to see if the caret has moved as a result of the keystroke that we have
     61        not already explicitly handled. If it has moved and was not handled, we dismiss the autocompletion suggestions.
     62
     63        (WI.SpreadsheetTextField.prototype._handleInput):
     64
     65        (WI.SpreadsheetTextField.prototype._updateCompletions):
     66        Provide the current caret position to the completion provided.
     67
     68        (WI.SpreadsheetTextField.prototype._showSuggestionsView):
     69        In order to correctly align the completion list with the current text content, it must be offset from the caret
     70        position excluding the current prefix.
     71
     72        (WI.SpreadsheetTextField.prototype._getCaretPosition):
     73        Added to get the index of the caret in the complete text value, accounting for multi-line values and mid-line
     74        suggestions.
     75
     76        (WI.SpreadsheetTextField.prototype._getCaretRect):
     77
     78        (WI.SpreadsheetTextField.prototype._rangeAtCaretPosition):
     79        Find the range for a caret at the given position. This compliments `_getCaretPosition`, and allows us to count
     80        back some number of characters and create a range of which we later get the client rectangle.
     81
     82        (WI.SpreadsheetTextField.prototype._applyCompletionHint):
     83        Add optional support for updating the caret position to be at the end of the newly inserted text.
     84
     85        (WI.SpreadsheetTextField.prototype._combineEditorElementChildren):
     86        Added to handle combining the fragmented text nodes (and possibly a suggestion hint element) back into a single
     87        text node while maintaining the current cursor position or optionally moving the cursor to a new location (e.g.
     88        the end of a completion).
     89
     90        (WI.SpreadsheetTextField.prototype._reAttachSuggestionHint):
     91        Now that completions are not guaranteed to be at the end of the value, we may need to split the text node to
     92        insert the suggestion hint node.
     93
    1942021-11-15  Fujii Hironori  <Hironori.Fujii@sony.com>
    295
  • trunk/Source/WebInspectorUI/UserInterface/Views/SpreadsheetStyleProperty.js

    r283723 r285851  
    964964    }
    965965
    966     _nameCompletionDataProvider(text, {allowEmptyPrefix} = {})
    967     {
    968         return WI.CSSKeywordCompletions.forPartialPropertyName(text, {allowEmptyPrefix});
     966    _nameCompletionDataProvider(text, {caretPosition, allowEmptyPrefix} = {})
     967    {
     968        return WI.CSSKeywordCompletions.forPartialPropertyName(text, {caretPosition, allowEmptyPrefix});
    969969    }
    970970
     
    988988    }
    989989
    990     _valueCompletionDataProvider(text, {allowEmptyPrefix} = {})
    991     {
    992         // FIXME: <webkit.org/b/227411> Styles sidebar panel should support midline and multiline completions.
    993         return WI.CSSKeywordCompletions.forPartialPropertyValue(text, this._nameElement.textContent.trim(), {additionalFunctionValueCompletionsProvider: this.additionalFunctionValueCompletionsProvider.bind(this)});
     990    _valueCompletionDataProvider(text, {caretPosition, allowEmptyPrefix} = {})
     991    {
     992        return WI.CSSKeywordCompletions.forPartialPropertyValue(text, this._nameElement.textContent.trim(), {caretPosition, additionalFunctionValueCompletionsProvider: this.additionalFunctionValueCompletionsProvider.bind(this)});
    994993    }
    995994
  • trunk/Source/WebInspectorUI/UserInterface/Views/SpreadsheetTextField.js

    r252523 r285851  
    11/*
    2  * Copyright (C) 2017 Apple Inc. All rights reserved.
     2 * Copyright (C) 2021 Apple Inc. All rights reserved.
    33 *
    44 * Redistribution and use in source and binary forms, with or without
     
    4545        this._element.addEventListener("blur", this._handleBlur.bind(this));
    4646        this._element.addEventListener("keydown", this._handleKeyDown.bind(this));
     47        this._element.addEventListener("keyup", this._handleKeyUp.bind(this));
    4748        this._element.addEventListener("input", this._handleInput.bind(this));
    4849
    4950        this._editing = false;
     51        this._preventDiscardingCompletionsOnKeyUp = false;
     52        this._keyDownCaretPosition = -1;
    5053        this._valueBeforeEditing = "";
    5154        this._completionPrefix = "";
     
    6467    valueWithoutSuggestion()
    6568    {
    66         let value = this._element.textContent;
    67         return value.slice(0, value.length - this.suggestionHint.length);
     69        // The suggestion could appear anywhere within the element, and the text of the element can span multiple nodes.
     70        let valueWithoutSuggestion = "";
     71        for (let childNode of this._element.childNodes) {
     72            if (childNode === this._suggestionHintElement)
     73                continue;
     74            valueWithoutSuggestion += childNode.textContent;
     75        }
     76        return valueWithoutSuggestion;
    6877    }
    6978
     
    7584    set suggestionHint(value)
    7685    {
     86        if (this._suggestionHintElement.textContent === value)
     87            return;
     88
    7789        this._suggestionHintElement.textContent = value;
    7890
    79         if (value)
     91        if (value) {
    8092            this._reAttachSuggestionHint();
    81         else
    82             this._suggestionHintElement.remove();
     93            return;
     94        }
     95
     96        this._suggestionHintElement.remove();
     97
     98        // Removing the suggestion hint element may leave the contents of `_element` fragmented into multiple text nodes.
     99        this._combineEditorElementChildren();
    83100    }
    84101
     
    93110        this._editing = true;
    94111        this._valueBeforeEditing = this.value;
     112
     113        this._keyDownCaretPosition = -1;
    95114
    96115        this._element.classList.add("editing");
     
    143162        this.suggestionHint = selectedText.slice(this._completionPrefix.length);
    144163
    145         this._reAttachSuggestionHint();
     164        if (this.suggestionHint.length)
     165            this._reAttachSuggestionHint();
    146166
    147167        if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
     
    151171    completionSuggestionsClickedCompletion(suggestionsView, selectedText)
    152172    {
    153         // Consider the following example:
    154         //
    155         //   border: 1px solid ro|
    156         //                     rosybrown
    157         //                     royalblue
    158         //
    159         // Clicking on "rosybrown" should replace "ro" with "rosybrown".
    160         //
    161         //           prefix:  1px solid ro
    162         // completionPrefix:            ro
    163         //        newPrefix:  1px solid
    164         //     selectedText:            rosybrown
    165         let prefix = this.valueWithoutSuggestion();
    166         let newPrefix = prefix.slice(0, -this._completionPrefix.length);
    167 
    168         this._element.textContent = newPrefix + selectedText;
    169 
    170         // Place text caret at the end.
    171         window.getSelection().setBaseAndExtent(this._element, selectedText.length, this._element, selectedText.length);
    172 
     173        this.suggestionHint = selectedText.slice(this._completionPrefix.length);
     174
     175        this._applyCompletionHint({moveCaretToEndOfCompletion: true});
    173176        this.discardCompletion();
    174177
     
    201204    _handleMouseDown(event)
    202205    {
    203         if (this._editing)
    204             event.stopPropagation();
     206        if (!this._editing)
     207            return;
     208
     209        event.stopPropagation();
     210        this.discardCompletion();
    205211    }
    206212
     
    227233            return;
    228234
     235        this._preventDiscardingCompletionsOnKeyUp = false;
     236        this._keyDownCaretPosition = this._getCaretPosition();
     237
    229238        if (this._suggestionsView) {
    230239            let consumed = this._handleKeyDownForSuggestionView(event);
     240            this._preventDiscardingCompletionsOnKeyUp = consumed;
    231241            if (consumed)
    232242                return;
     
    293303                this._suggestionsView.hide();
    294304            else {
     305                this._preventDiscardingCompletionsOnKeyUp = true;
    295306                const forceCompletions = true;
    296307                this._updateCompletions(forceCompletions);
     
    328339        }
    329340
    330         if (event.key === "ArrowRight" && this.suggestionHint) {
     341        if (event.key === "ArrowRight" && this.suggestionHint.length) {
    331342            let selection = window.getSelection();
    332343
    333             if (selection.isCollapsed && (selection.focusOffset === this.valueWithoutSuggestion().length || selection.focusNode === this._suggestionHintElement)) {
     344            if (selection.isCollapsed) {
    334345                event.stop();
    335                 document.execCommand("insertText", false, this.suggestionHint);
     346                this._applyCompletionHint({moveCaretToEndOfCompletion: true});
    336347
    337348                // When completing "background", don't hide the completion popover.
     
    363374            if (this._delegate && typeof this._delegate.spreadsheetTextFieldDidChange === "function")
    364375                this._delegate.spreadsheetTextFieldDidChange(this);
     376            return true;
    365377        }
    366378
     
    368380    }
    369381
     382    _handleKeyUp()
     383    {
     384        if (!this._editing || !this._suggestionsView)
     385            return;
     386
     387        // Certain actions, like Ctrl+Space will handle updating or discarding completions as necessary.
     388        if (this._preventDiscardingCompletionsOnKeyUp)
     389            return;
     390
     391        // Some key events, like the arrow keys and Ctrl+A (move to line start), will move the caret without committing
     392        // any input to the text field. In those situations we should discard completion if they are available. It is
     393        // also possible that we receive a KeyUp event for a key that was not pressed inside this text field, in which
     394        // case the _keyDownCaretPosition will still be -1. This can occur when the user types a `:` to begin editing
     395        // the value for a property, and the KeyUp events for each of those keys will be handled here, even though the
     396        // corresponding KeyDown events was never handled by this text field.
     397        if (this._keyDownCaretPosition === -1 || this._keyDownCaretPosition === this._getCaretPosition())
     398            return;
     399
     400        this.discardCompletion();
     401    }
     402
    370403    _handleInput(event)
    371404    {
     
    373406            return;
    374407
     408        this._preventDiscardingCompletionsOnKeyUp = true;
    375409        this._updateCompletions();
    376410
     
    385419
    386420        let valueWithoutSuggestion = this.valueWithoutSuggestion();
    387         let {completions, prefix} = this._completionProvider(valueWithoutSuggestion, {allowEmptyPrefix: forceCompletions});
     421        let {completions, prefix} = this._completionProvider(valueWithoutSuggestion, {allowEmptyPrefix: forceCompletions, caretPosition: this._getCaretPosition()});
    388422        this._completionPrefix = prefix;
    389423
     
    423457    _showSuggestionsView()
    424458    {
    425         let prefix = this.valueWithoutSuggestion();
    426         let startOffset = prefix.length - this._completionPrefix.length;
    427         let caretRect = this._getCaretRect(startOffset);
     459        // Adjust the used caret position to correctly align autocompletion results with existing text. The suggestions
     460        // should appear aligned as below:
     461        //
     462        // border: 1px solid ro|
     463        //                   rosybrown
     464        //                   royalblue
     465        let adjustedCaretPosition = this._getCaretPosition() - this._completionPrefix.length;
     466        let caretRect = this._getCaretRect(adjustedCaretPosition);
    428467
    429468        // Hide completion popover when the anchor element is removed from the DOM.
     
    436475    }
    437476
    438     _getCaretRect(startOffset)
     477    _getCaretPosition()
    439478    {
    440479        let selection = window.getSelection();
    441 
     480        if (!selection.rangeCount)
     481            return 0;
     482
     483        // The window's selection range will only contain the current line's positioning in multiline text, so a new
     484        // range must be created between the end of the current range and the beginning of the first line of text in
     485        // order to get an accurate character position for the caret.
     486        let lineRange = selection.getRangeAt(0);
     487        let multilineRange = document.createRange();
     488        multilineRange.setStart(this._element, 0);
     489        multilineRange.setEnd(lineRange.endContainer, lineRange.endOffset);
     490        return multilineRange.toString().length;
     491    }
     492
     493    _getCaretRect(caretPosition)
     494    {
    442495        let isHidden = (clientRect) => {
    443496            return clientRect.x === 0 && clientRect.y === 0;
    444497        };
    445498
    446         if (selection.rangeCount) {
    447             let range = selection.getRangeAt(0).cloneRange();
    448             range.setStart(range.startContainer, startOffset);
    449             let clientRect = range.getBoundingClientRect();
    450 
    451             if (!isHidden(clientRect)) {
    452                 // This happens after deleting value. However, when focusing
    453                 // on an empty value clientRect is visible.
    454                 return WI.Rect.rectFromClientRect(clientRect);
     499        let caretRange = this._rangeAtCaretPosition(caretPosition);
     500        let caretClientRect = caretRange.getBoundingClientRect();
     501        if (!isHidden(caretClientRect))
     502            return WI.Rect.rectFromClientRect(caretClientRect);
     503
     504        let elementClientRect = this._element.getBoundingClientRect();
     505        if (isHidden(elementClientRect))
     506            return null;
     507
     508        const leftPadding = parseInt(getComputedStyle(this._element).paddingLeft) || 0;
     509        return new WI.Rect(elementClientRect.left + leftPadding, elementClientRect.top, elementClientRect.width, elementClientRect.height);
     510    }
     511
     512    _rangeAtCaretPosition(caretPosition) {
     513        for (let node of this._element.childNodes) {
     514            let textContent = node.textContent;
     515            if (caretPosition <= textContent.length) {
     516                let range = document.createRange();
     517                range.setStart(node, caretPosition);
     518                range.setEnd(node, caretPosition);
     519                return range;
    455520            }
    456         }
    457 
    458         let clientRect = this._element.getBoundingClientRect();
    459         if (isHidden(clientRect))
    460             return null;
    461 
    462         const leftPadding = parseInt(getComputedStyle(this._element).paddingLeft) || 0;
    463         return new WI.Rect(clientRect.left + leftPadding, clientRect.top, clientRect.width, clientRect.height);
    464     }
    465 
    466     _applyCompletionHint()
     521
     522            caretPosition -= textContent.length;
     523        }
     524
     525        // If there are no nodes, or the caret position is greater than the total text content, provide the range of a
     526        // caret at the end of the element.
     527        let range = document.createRange();
     528        range.selectNodeContents(this._element);
     529        range.collapse();
     530        return range;
     531    }
     532
     533    _applyCompletionHint({moveCaretToEndOfCompletion} = {})
    467534    {
    468535        if (!this._completionProvider || !this.suggestionHint)
    469536            return;
    470537
     538        this._combineEditorElementChildren({newCaretPosition: moveCaretToEndOfCompletion ? this._getCaretPosition() + this.suggestionHint.length : null});
     539    }
     540
     541    _combineEditorElementChildren({newCaretPosition} = {})
     542    {
     543        newCaretPosition ??= this._getCaretPosition();
     544
     545        // Setting the textContent of the element to its current textContent will take the text from the multiple
     546        // potential child nodes (potentially a suggestion hint node and some number of existing text nodes) and turn
     547        // them into a single text node within the element.
    471548        this._element.textContent = this._element.textContent;
     549
     550        if (this._element.textContent.length) {
     551            let textChildNode = this._element.firstChild;
     552            window.getSelection().setBaseAndExtent(textChildNode, newCaretPosition, textChildNode, newCaretPosition);
     553        }
    472554    }
    473555
    474556    _reAttachSuggestionHint()
    475557    {
     558        console.assert(this.suggestionHint.length, "Suggestion hint should not be empty when attaching the suggestion hint element.");
     559
    476560        if (this._suggestionHintElement.parentElement === this._element)
    477561            return;
    478562
    479         this._element.append(this._suggestionHintElement);
     563        let selection = window.getSelection();
     564        if (!this._element.textContent.length || !selection.rangeCount) {
     565            this._element.append(this._suggestionHintElement);
     566            return;
     567        }
     568
     569        let range = selection.getRangeAt(0);
     570
     571        console.assert(range.endContainer instanceof Text, range.endContainer);
     572        if (!(range.endContainer instanceof Text))
     573            return;
     574
     575        this._element.insertBefore(this._suggestionHintElement, range.endContainer.splitText(range.endOffset));
    480576    }
    481577};
Note: See TracChangeset for help on using the changeset viewer.