 a2df326368
			
		
	
	a2df326368
	
	
	
		
			
			gr-textarea did not take into account scroll position when positioning the emoji dropdown. This fixes it by adding scrollTop to non-fixed position textareas. Change-Id: I490d03443b4ca910602ab0636f3dfc19957fdd89
		
			
				
	
	
		
			321 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			321 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| // Copyright (C) 2017 The Android Open Source Project
 | |
| //
 | |
| // Licensed under the Apache License, Version 2.0 (the "License");
 | |
| // you may not use this file except in compliance with the License.
 | |
| // You may obtain a copy of the License at
 | |
| //
 | |
| // http://www.apache.org/licenses/LICENSE-2.0
 | |
| //
 | |
| // Unless required by applicable law or agreed to in writing, software
 | |
| // distributed under the License is distributed on an "AS IS" BASIS,
 | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | |
| // See the License for the specific language governing permissions and
 | |
| // limitations under the License.
 | |
| (function() {
 | |
|   'use strict';
 | |
| 
 | |
|   const MAX_ITEMS_DROPDOWN = 10;
 | |
|   const VERTICAL_OFFSET = 7;
 | |
| 
 | |
|   const ALL_SUGGESTIONS = [
 | |
|     {value: '💯', match: '100'},
 | |
|     {value: '💔', match: 'broken heart'},
 | |
|     {value: '🍺', match: 'beer'},
 | |
|     {value: '✔', match: 'check'},
 | |
|     {value: '😎', match: 'cool'},
 | |
|     {value: '😕', match: 'confused'},
 | |
|     {value: '😭', match: 'crying'},
 | |
|     {value: '🔥', match: 'fire'},
 | |
|     {value: '👊', match: 'fistbump'},
 | |
|     {value: '🐨', match: 'koala'},
 | |
|     {value: '😄', match: 'laugh'},
 | |
|     {value: '🤓', match: 'glasses'},
 | |
|     {value: '😆', match: 'grin'},
 | |
|     {value: '😐', match: 'neutral'},
 | |
|     {value: '👌', match: 'ok'},
 | |
|     {value: '🎉', match: 'party'},
 | |
|     {value: '💩', match: 'poop'},
 | |
|     {value: '🙏', match: 'pray'},
 | |
|     {value: '😞', match: 'sad'},
 | |
|     {value: '😮', match: 'shock'},
 | |
|     {value: '😊', match: 'smile'},
 | |
|     {value: '😢', match: 'tear'},
 | |
|     {value: '😂', match: 'tears'},
 | |
|     {value: '😋', match: 'tongue'},
 | |
|     {value: '👍', match: 'thumbs up'},
 | |
|     {value: '👎', match: 'thumbs down'},
 | |
|     {value: '😒', match: 'unamused'},
 | |
|     {value: '😉', match: 'wink'},
 | |
|     {value: '🍷', match: 'wine'},
 | |
|     {value: '😜', match: 'winking tongue'},
 | |
|   ];
 | |
| 
 | |
|   Polymer({
 | |
|     is: 'gr-textarea',
 | |
| 
 | |
|     properties: {
 | |
|       autocomplete: Boolean,
 | |
|       disabled: Boolean,
 | |
|       rows: Number,
 | |
|       maxRows: Number,
 | |
|       placeholder: String,
 | |
|       fixedPositionDropdown: Boolean,
 | |
|       moveToRoot: Boolean,
 | |
|       text: {
 | |
|         type: String,
 | |
|         notify: true,
 | |
|       },
 | |
|       backgroundColor: {
 | |
|         type: String,
 | |
|         value: '#fff',
 | |
|       },
 | |
|       hideBorder: {
 | |
|         type: Boolean,
 | |
|         value: false,
 | |
|       },
 | |
|       monospace: {
 | |
|         type: Boolean,
 | |
|         value: false,
 | |
|       },
 | |
|       _colonIndex: Number,
 | |
|       _currentSearchString: {
 | |
|         type: String,
 | |
|         value: '',
 | |
|         observer: '_determineSuggestions',
 | |
|       },
 | |
|       _hideAutocomplete: {
 | |
|         type: Boolean,
 | |
|         value: true,
 | |
|       },
 | |
|       _index: Number,
 | |
|       _suggestions: Array,
 | |
|     },
 | |
| 
 | |
|     behaviors: [
 | |
|       Gerrit.KeyboardShortcutBehavior,
 | |
|     ],
 | |
| 
 | |
|     keyBindings: {
 | |
|       esc: '_handleEscKey',
 | |
|       tab: '_handleEnterByKey',
 | |
|       enter: '_handleEnterByKey',
 | |
|       up: '_handleUpKey',
 | |
|       down: '_handleDownKey',
 | |
|     },
 | |
| 
 | |
|     ready() {
 | |
|       this._resetEmojiDropdown();
 | |
|       if (this.monospace) {
 | |
|         this.classList.add('monospace');
 | |
|       }
 | |
|       if (this.hideBorder) {
 | |
|         this.$.textarea.classList.add('noBorder');
 | |
|       }
 | |
|       if (this.backgroundColor) {
 | |
|         this.customStyle['--background-color'] = this.backgroundColor;
 | |
|         this.updateStyles();
 | |
|       }
 | |
|       this.listen(this.$.emojiSuggestions, 'dropdown-closed', '_resetAndFocus');
 | |
|       this.listen(this.$.emojiSuggestions, 'item-selected',
 | |
|           '_handleEmojiSelect');
 | |
|     },
 | |
| 
 | |
|     detached() {
 | |
|       this.closeDropdown();
 | |
|       this.listen(this.$.emojiSuggestions, 'dropdown-closed', '_resetAndFocus');
 | |
|       this.listen(this.$.emojiSuggestions, 'item-selected',
 | |
|           '_handleEmojiSelect');
 | |
|     },
 | |
| 
 | |
|     closeDropdown() {
 | |
|       if (!this.$.emojiSuggestions.hidden) {
 | |
|         this._closeEmojiDropdown();
 | |
|       }
 | |
|     },
 | |
| 
 | |
|     getNativeTextarea() {
 | |
|       return this.$.textarea.textarea;
 | |
|     },
 | |
| 
 | |
|     putCursorAtEnd() {
 | |
|       const textarea = this.getNativeTextarea();
 | |
|       // Put the cursor at the end always.
 | |
|       textarea.selectionStart = textarea.value.length;
 | |
|       textarea.selectionEnd = textarea.selectionStart;
 | |
|       this.async(() => {
 | |
|         textarea.focus();
 | |
|       });
 | |
|     },
 | |
| 
 | |
|     _handleEscKey(e) {
 | |
|       if (this._hideAutocomplete) { return; }
 | |
|       e.preventDefault();
 | |
|       e.stopPropagation();
 | |
|       this._resetAndFocus();
 | |
|     },
 | |
| 
 | |
|     _resetAndFocus() {
 | |
|       this._resetEmojiDropdown();
 | |
|       this.$.textarea.textarea.focus();
 | |
|     },
 | |
| 
 | |
|     _handleUpKey(e) {
 | |
|       if (this._hideAutocomplete) { return; }
 | |
|       e.preventDefault();
 | |
|       e.stopPropagation();
 | |
|       this.$.emojiSuggestions.cursorUp();
 | |
|     },
 | |
| 
 | |
|     _handleDownKey(e) {
 | |
|       if (this._hideAutocomplete) { return; }
 | |
|       e.preventDefault();
 | |
|       e.stopPropagation();
 | |
|       this.$.emojiSuggestions.cursorDown();
 | |
|     },
 | |
| 
 | |
|     _handleEnterByKey(e) {
 | |
|       if (this._hideAutocomplete) { return; }
 | |
|       e.preventDefault();
 | |
|       e.stopPropagation();
 | |
|       this.text = this._getText(this.$.emojiSuggestions.getCurrentText());
 | |
|       this._resetEmojiDropdown();
 | |
|     },
 | |
| 
 | |
|     _handleEmojiSelect(e) {
 | |
|       this.text = this._getText(e.detail.selected.dataset.value);
 | |
|       this._resetEmojiDropdown();
 | |
|     },
 | |
| 
 | |
|     _getText(value) {
 | |
|       return this.text.substr(0, this._colonIndex) +
 | |
|           value + this.text.substr(this.$.textarea.selectionStart) + ' ';
 | |
|     },
 | |
| 
 | |
|     _getPositionOfCursor() {
 | |
|       this.$.hiddenText.textContent = this.$.textarea.value.substr(0,
 | |
|           this.$.textarea.selectionStart);
 | |
| 
 | |
|       const caratSpan = document.createElement('span');
 | |
|       this.$.hiddenText.appendChild(caratSpan);
 | |
|       return caratSpan.getBoundingClientRect();
 | |
|     },
 | |
| 
 | |
|     _getFontSize() {
 | |
|       const fontSizePx = getComputedStyle(this).fontSize || '12px';
 | |
|       return parseInt(fontSizePx.substr(0, fontSizePx.length - 2),
 | |
|           10);
 | |
|     },
 | |
| 
 | |
|     _getScrollTop() {
 | |
|       return document.body.scrollTop;
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * This positions the dropdown to be just below the cursor position. It is
 | |
|      * calculated by having a hidden element with the same width and styling of
 | |
|      * the tetarea and the text up until the point of interest. Then a span
 | |
|      * element is added to the end so that there is a specific element to get
 | |
|      * the position of.  Line height is determined (or falls back to 12px) as
 | |
|      * extra height to add.
 | |
|      */
 | |
|     _updateSelectorPosition() {
 | |
|       // These are broken out into separate functions for testability.
 | |
|       const caratPosition = this._getPositionOfCursor();
 | |
|       const fontSize = this._getFontSize();
 | |
| 
 | |
|       let top = caratPosition.top + fontSize + VERTICAL_OFFSET;
 | |
| 
 | |
|       if (!this.fixedPositionDropdown) {
 | |
|         top += this._getScrollTop();
 | |
|       }
 | |
|       top += 'px';
 | |
|       const left = caratPosition.left + 'px';
 | |
|       this.$.emojiSuggestions.setPosition(top, left);
 | |
|     },
 | |
| 
 | |
|     /**
 | |
|      * _handleKeydown used for key handling in the this.$.textarea AND all child
 | |
|      * autocomplete options.
 | |
|      */
 | |
|     _onValueChanged(e) {
 | |
|       // If cursor is not in textarea (just opened with colon as last char),
 | |
|       // Don't do anything.
 | |
|       if (!e.currentTarget.focused) { return; }
 | |
|       const newChar = e.detail.value[this.$.textarea.selectionStart - 1];
 | |
| 
 | |
|       // When a colon is detected, set a colon index, but don't do anything else
 | |
|       // yet.
 | |
|       if (newChar === ':') {
 | |
|         this._colonIndex = this.$.textarea.selectionStart - 1;
 | |
|       // If the colon index exists, continue to determine what needs to be done
 | |
|       // with the dropdown. It may be open or closed at this point.
 | |
|       } else if (this._colonIndex !== null) {
 | |
|         // The search string is a substring of the textarea's value from (1
 | |
|         // position after) the colon index to the cursor position.
 | |
|         this._currentSearchString = e.detail.value.substr(this._colonIndex + 1,
 | |
|             this.$.textarea.selectionStart);
 | |
|         // Under the following conditions, close and reset the dropdown:
 | |
|         // - The cursor is no longer at the end of the current search string
 | |
|         // - The search string is an space or new line
 | |
|         // - The colon has been removed
 | |
|         // - There are no suggestions that match the search string
 | |
|         if (this.$.textarea.selectionStart !==
 | |
|             this._currentSearchString.length + this._colonIndex + 1 ||
 | |
|             this._currentSearchString === ' ' ||
 | |
|             this._currentSearchString === '\n' ||
 | |
|             !e.detail.value[this._colonIndex] === ':' ||
 | |
|             !this._suggestions.length) {
 | |
|           this._resetEmojiDropdown();
 | |
|         // Otherwise open the dropdown and set the position to be just below the
 | |
|         // cursor.
 | |
|         } else if (this.$.emojiSuggestions.hidden) {
 | |
|           this._hideAutocomplete = false;
 | |
|           this._openEmojiDropdown();
 | |
|           this._updateSelectorPosition();
 | |
|         }
 | |
|         this.$.textarea.textarea.focus();
 | |
|       }
 | |
|     },
 | |
| 
 | |
|     _closeEmojiDropdown() {
 | |
|       this.$.emojiSuggestions.close();
 | |
|       this.$.emojiSuggestions.hidden = true;
 | |
|     },
 | |
| 
 | |
|     _openEmojiDropdown() {
 | |
|       this.$.emojiSuggestions.open();
 | |
|       this.$.emojiSuggestions.hidden = false;
 | |
|     },
 | |
| 
 | |
|     _formatSuggestions(matchedSuggestions) {
 | |
|       const suggestions = [];
 | |
|       for (const suggestion of matchedSuggestions) {
 | |
|         suggestion.dataValue = suggestion.value;
 | |
|         suggestion.text = suggestion.value + ' ' + suggestion.match;
 | |
|         suggestions.push(suggestion);
 | |
|       }
 | |
|       this._suggestions = suggestions;
 | |
|     },
 | |
| 
 | |
|     _determineSuggestions(emojiText) {
 | |
|       if (!emojiText.length) {
 | |
|         this._formatSuggestions(ALL_SUGGESTIONS);
 | |
|       }
 | |
|       const matches = ALL_SUGGESTIONS.filter(suggestion => {
 | |
|         return suggestion.match.includes(emojiText);
 | |
|       }).splice(0, MAX_ITEMS_DROPDOWN);
 | |
|       this._formatSuggestions(matches);
 | |
|     },
 | |
| 
 | |
|     _resetEmojiDropdown() {
 | |
|       // hide and reset the autocomplete dropdown.
 | |
|       Polymer.dom.flush();
 | |
|       this._currentSearchString = '';
 | |
|       this._hideAutocomplete = true;
 | |
|       this.closeDropdown();
 | |
|       this._colonIndex = null;
 | |
|       this.$.textarea.textarea.focus();
 | |
|     },
 | |
|   });
 | |
| })();
 |