blob: 6125d33138d0271eb589808891cac48b0a9fc2c2 [file] [log] [blame]
/**
* @license
* 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.
*/
import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
import '../gr-cursor-manager/gr-cursor-manager';
import '../gr-overlay/gr-overlay';
import '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import '../../../styles/shared-styles';
import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-textarea_html';
import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {appContext} from '../../../services/app-context';
import {customElement, property} from '@polymer/decorators';
import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
const MAX_ITEMS_DROPDOWN = 10;
const ALL_SUGGESTIONS: EmojiSuggestion[] = [
{value: '😊', match: 'smile :)'},
{value: '👍', match: 'thumbs up'},
{value: '😄', match: 'laugh :D'},
{value: '❤️', match: 'heart <3'},
{value: '😂', match: "tears :')"},
{value: '🎉', match: 'party'},
{value: '😎', match: 'cool |;)'},
{value: '😞', match: 'sad :('},
{value: '😐', match: 'neutral :|'},
{value: '😮', match: 'shock :O'},
{value: '🙏', match: 'pray'},
{value: '😕', match: 'confused'},
{value: '👌', match: 'ok'},
{value: '🔥', match: 'fire'},
{value: '💯', match: '100'},
{value: '✔', match: 'check'},
{value: '😋', match: 'tongue'},
{value: '😭', match: "crying :'("},
{value: '🤓', match: 'glasses'},
{value: '😢', match: 'tear'},
{value: '😜', match: 'winking tongue ;)'},
];
interface EmojiSuggestion {
value: string;
match: string;
dataValue?: string;
text?: string;
}
interface ValueChangeEvent {
value: string;
}
export interface GrTextarea {
$: {
textarea: IronAutogrowTextareaElement;
emojiSuggestions: GrAutocompleteDropdown;
caratSpan: HTMLSpanElement;
hiddenText: HTMLDivElement;
};
}
@customElement('gr-textarea')
export class GrTextarea extends KeyboardShortcutMixin(PolymerElement) {
static get template() {
return htmlTemplate;
}
/**
* @event bind-value-changed
*/
@property({type: Boolean})
autocomplete?: boolean;
@property({type: Boolean})
disabled?: boolean;
@property({type: Number})
rows?: number;
@property({type: Number})
maxRows?: number;
@property({type: String})
placeholder?: string;
@property({type: String, notify: true, observer: '_handleTextChanged'})
text?: string;
@property({type: Boolean})
hideBorder = false;
/** Text input should be rendered in monospace font. */
@property({type: Boolean})
monospace = false;
/** Text input should be rendered in code font, which is smaller than the
standard monospace font. */
@property({type: Boolean})
code = false;
@property({type: Number})
_colonIndex: number | null = null;
@property({type: String, observer: '_determineSuggestions'})
_currentSearchString?: string;
@property({type: Boolean})
_hideAutocomplete = true;
@property({type: Number})
_index?: number;
@property({type: Array})
_suggestions?: EmojiSuggestion[];
@property({type: Number})
readonly _verticalOffset = 20;
// Offset makes dropdown appear below text.
reporting: ReportingService;
disableEnterKeyForSelectingEmoji = false;
get keyBindings() {
return {
esc: '_handleEscKey',
tab: '_handleEnterByKey',
enter: '_handleEnterByKey',
up: '_handleUpKey',
down: '_handleDownKey',
};
}
constructor() {
super();
this.reporting = appContext.reportingService;
}
/** @override */
ready() {
super.ready();
if (this.monospace) {
this.classList.add('monospace');
}
if (this.code) {
this.classList.add('code');
}
if (this.hideBorder) {
this.$.textarea.classList.add('noBorder');
}
}
closeDropdown() {
return this.$.emojiSuggestions.close();
}
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;
setTimeout(() => {
textarea.focus();
});
}
_handleEscKey(e: KeyboardEvent) {
if (this._hideAutocomplete) {
return;
}
e.preventDefault();
e.stopPropagation();
this._resetEmojiDropdown();
}
_handleUpKey(e: KeyboardEvent) {
if (this._hideAutocomplete) {
return;
}
e.preventDefault();
e.stopPropagation();
this.$.emojiSuggestions.cursorUp();
this.$.textarea.textarea.focus();
this.disableEnterKeyForSelectingEmoji = false;
}
_handleDownKey(e: KeyboardEvent) {
if (this._hideAutocomplete) {
return;
}
e.preventDefault();
e.stopPropagation();
this.$.emojiSuggestions.cursorDown();
this.$.textarea.textarea.focus();
this.disableEnterKeyForSelectingEmoji = false;
}
_handleEnterByKey(e: KeyboardEvent) {
if (this._hideAutocomplete || this.disableEnterKeyForSelectingEmoji) {
return;
}
e.preventDefault();
e.stopPropagation();
this._setEmoji(this.$.emojiSuggestions.getCurrentText());
}
_handleEmojiSelect(e: CustomEvent) {
this._setEmoji(e.detail.selected.dataset['value']);
}
_setEmoji(text: string) {
if (this._colonIndex === null) {
return;
}
const colonIndex = this._colonIndex;
this.text = this._getText(text);
this.$.textarea.selectionStart = colonIndex + 1;
this.$.textarea.selectionEnd = colonIndex + 1;
this.reporting.reportInteraction('select-emoji', {type: text});
this._resetEmojiDropdown();
}
_getText(value: string) {
if (!this.text) return '';
return (
this.text.substr(0, this._colonIndex || 0) +
value +
this.text.substr(this.$.textarea.selectionStart)
);
}
/**
* Uses a hidden element with the same width and styling of the textarea and
* the text up until the point of interest. Then caratSpan element is added
* to the end and is set to be the positionTarget for the dropdown. Together
* this allows the dropdown to appear near where the user is typing.
*/
_updateCaratPosition() {
this._hideAutocomplete = false;
if (typeof this.$.textarea.value === 'string') {
this.$.hiddenText.textContent = this.$.textarea.value.substr(
0,
this.$.textarea.selectionStart
);
}
const caratSpan = this.$.caratSpan;
this.$.hiddenText.appendChild(caratSpan);
this.$.emojiSuggestions.positionTarget = caratSpan;
this._openEmojiDropdown();
}
/**
* _handleKeydown used for key handling in the this.$.textarea AND all child
* autocomplete options.
*/
_onValueChanged(e: CustomEvent<ValueChangeEvent>) {
// Relay the event.
this.dispatchEvent(
new CustomEvent('bind-value-changed', {
detail: e,
composed: true,
bubbles: true,
})
);
// If cursor is not in textarea (just opened with colon as last char),
// Don't do anything.
if (
e.currentTarget === null ||
!(e.currentTarget as IronAutogrowTextareaElement).focused
) {
return;
}
const charAtCursor =
e.detail && e.detail.value
? e.detail.value[this.$.textarea.selectionStart - 1]
: '';
if (charAtCursor !== ':' && this._colonIndex === null) {
return;
}
// When a colon is detected, set a colon index. We are interested only on
// colons after space or in beginning of textarea
if (charAtCursor === ':') {
if (
this.$.textarea.selectionStart < 2 ||
e.detail.value[this.$.textarea.selectionStart - 2] === ' '
) {
this._colonIndex = this.$.textarea.selectionStart - 1;
}
}
if (this._colonIndex === null) {
return;
}
this._currentSearchString = e.detail.value.substr(
this._colonIndex + 1,
this.$.textarea.selectionStart - this._colonIndex - 1
);
// 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 ||
!this._suggestions.length
) {
this._resetEmojiDropdown();
// Otherwise open the dropdown and set the position to be just below the
// cursor.
} else if (this.$.emojiSuggestions.isHidden) {
this._updateCaratPosition();
}
this.$.textarea.textarea.focus();
}
_openEmojiDropdown() {
this.$.emojiSuggestions.open();
this.reporting.reportInteraction('open-emoji-dropdown');
}
_formatSuggestions(matchedSuggestions: EmojiSuggestion[]) {
const suggestions = [];
for (const suggestion of matchedSuggestions) {
suggestion.dataValue = suggestion.value;
suggestion.text = suggestion.value + ' ' + suggestion.match;
suggestions.push(suggestion);
}
this.set('_suggestions', suggestions);
}
_determineSuggestions(emojiText: string) {
if (!emojiText.length) {
this._formatSuggestions(ALL_SUGGESTIONS);
this.disableEnterKeyForSelectingEmoji = true;
} else {
const matches = ALL_SUGGESTIONS.filter(suggestion =>
suggestion.match.includes(emojiText)
).slice(0, MAX_ITEMS_DROPDOWN);
this._formatSuggestions(matches);
this.disableEnterKeyForSelectingEmoji = false;
}
}
_resetEmojiDropdown() {
// hide and reset the autocomplete dropdown.
flush();
this._currentSearchString = '';
this._hideAutocomplete = true;
this.closeDropdown();
this._colonIndex = null;
this.$.textarea.textarea.focus();
}
_handleTextChanged(text: string) {
this.dispatchEvent(
new CustomEvent('value-changed', {detail: {value: text}})
);
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-textarea': GrTextarea;
}
}