blob: e0c49c9f631e0eec6dec7944daa18b0480196825 [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-autogrow-textarea/iron-autogrow-textarea';
import '../../../styles/shared-styles';
import {getAppContext} from '../../../services/app-context';
import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import {
GrAutocompleteDropdown,
Item,
ItemSelectedEvent,
} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
import {addShortcut, Key} from '../../../utils/dom-util';
import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
import {fire} from '../../../utils/event-util';
import {LitElement, css, html} from 'lit';
import {customElement, property, query, state} from 'lit/decorators';
import {sharedStyles} from '../../../styles/shared-styles';
import {PropertyValues} from 'lit';
import {classMap} from 'lit/directives/class-map';
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 extends Item {
match: string;
}
declare global {
interface HTMLElementEventMap {
'item-selected': CustomEvent<ItemSelectedEvent>;
}
}
@customElement('gr-textarea')
export class GrTextarea extends LitElement {
/**
* @event bind-value-changed
*/
@query('#textarea') textarea?: IronAutogrowTextareaElement;
@query('#emojiSuggestions') emojiSuggestions?: GrAutocompleteDropdown;
@query('#caratSpan', true) caratSpan?: HTMLSpanElement;
@query('#hiddenText') hiddenText?: HTMLDivElement;
@property() autocomplete?: string;
@property({type: Boolean}) disabled?: boolean;
@property({type: Number}) rows?: number;
@property({type: Number}) maxRows?: number;
@property({type: String}) placeholder?: string;
@property({type: String}) text = '';
@property({type: Boolean, attribute: 'hide-border'}) 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;
@state() colonIndex: number | null = null;
@state() currentSearchString?: string;
@state() hideEmojiAutocomplete = true;
@state() private index: number | null = null;
@state() suggestions: EmojiSuggestion[] = [];
// Accessed in tests.
readonly reporting = getAppContext().reportingService;
disableEnterKeyForSelectingEmoji = false;
/** Called in disconnectedCallback. */
private cleanups: (() => void)[] = [];
override disconnectedCallback() {
super.disconnectedCallback();
for (const cleanup of this.cleanups) cleanup();
this.cleanups = [];
}
override connectedCallback() {
super.connectedCallback();
if (this.monospace) {
this.classList.add('monospace');
}
if (this.code) {
this.classList.add('code');
}
this.cleanups.push(
addShortcut(this, {key: Key.UP}, e => this.handleUpKey(e), {
doNotPrevent: true,
})
);
this.cleanups.push(
addShortcut(this, {key: Key.DOWN}, e => this.handleDownKey(e), {
doNotPrevent: true,
})
);
this.cleanups.push(
addShortcut(this, {key: Key.TAB}, e => this.handleTabKey(e), {
doNotPrevent: true,
})
);
this.cleanups.push(
addShortcut(this, {key: Key.ENTER}, e => this.handleEnterByKey(e), {
doNotPrevent: true,
})
);
this.cleanups.push(
addShortcut(this, {key: Key.ESC}, e => this.handleEscKey(e), {
doNotPrevent: true,
})
);
}
static override styles = [
sharedStyles,
css`
:host {
display: flex;
position: relative;
}
:host(.monospace) {
font-family: var(--monospace-font-family);
font-size: var(--font-size-mono);
line-height: var(--line-height-mono);
font-weight: var(--font-weight-normal);
}
:host(.code) {
font-family: var(--monospace-font-family);
font-size: var(--font-size-code);
/* usually 16px = 12px + 4px */
line-height: calc(var(--font-size-code) + var(--spacing-s));
font-weight: var(--font-weight-normal);
}
#emojiSuggestions {
font-family: var(--font-family);
}
gr-autocomplete {
display: inline-block;
}
#textarea {
background-color: var(--view-background-color);
width: 100%;
}
#hiddenText #emojiSuggestions {
visibility: visible;
white-space: normal;
}
iron-autogrow-textarea {
position: relative;
}
#textarea.noBorder {
border: none;
}
#hiddenText {
display: block;
float: left;
position: absolute;
visibility: hidden;
width: 100%;
white-space: pre-wrap;
}
`,
];
override render() {
return html`
<div id="hiddenText"></div>
<!-- When the autocomplete is open, the span is moved at the end of
hiddenText in order to correctly position the dropdown. After being moved,
it is set as the positionTarget for the emojiSuggestions dropdown. -->
<span id="caratSpan"></span>
<gr-autocomplete-dropdown
id="emojiSuggestions"
.suggestions=${this.suggestions}
.index=${this.index}
.verticalOffset=${20}
@dropdown-closed=${this.resetEmojiDropdown}
@item-selected=${this.handleEmojiSelect}
>
</gr-autocomplete-dropdown>
<iron-autogrow-textarea
id="textarea"
class=${classMap({noBorder: this.hideBorder})}
.autocomplete=${this.autocomplete}
.placeholder=${this.placeholder}
?disabled=${this.disabled}
.rows=${this.rows}
.maxRows=${this.maxRows}
.value=${this.text}
@value-changed=${(e: ValueChangedEvent) => {
this.text = e.detail.value;
}}
@bind-value-changed=${this.onValueChanged}
></iron-autogrow-textarea>
`;
}
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('text')) {
this.handleTextChanged(this.text);
}
if (changedProperties.has('currentSearchString')) {
this.determineSuggestions(this.currentSearchString!);
}
}
// private but used in test
closeDropdown() {
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();
});
}
private handleEscKey(e: KeyboardEvent) {
if (this.hideEmojiAutocomplete) {
return;
}
e.preventDefault();
e.stopPropagation();
this.resetEmojiDropdown();
}
private handleUpKey(e: KeyboardEvent) {
if (this.hideEmojiAutocomplete) {
return;
}
e.preventDefault();
e.stopPropagation();
this.emojiSuggestions!.cursorUp();
this.textarea!.textarea.focus();
this.disableEnterKeyForSelectingEmoji = false;
}
private handleDownKey(e: KeyboardEvent) {
if (this.hideEmojiAutocomplete) {
return;
}
e.preventDefault();
e.stopPropagation();
this.emojiSuggestions!.cursorDown();
this.textarea!.textarea.focus();
this.disableEnterKeyForSelectingEmoji = false;
}
private handleTabKey(e: KeyboardEvent) {
// Tab should have normal behavior if the picker is closed or if the user
// has only typed ':'.
if (this.hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
return;
}
e.preventDefault();
e.stopPropagation();
this.setEmoji(this.emojiSuggestions!.getCurrentText());
}
// private but used in test
handleEnterByKey(e: KeyboardEvent) {
// Enter should have newline behavior if the picker is closed or if the user
// has only typed ':'. Also make sure that shortcuts aren't clobbered.
if (this.hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
this.indent(e);
return;
}
e.preventDefault();
e.stopPropagation();
this.setEmoji(this.emojiSuggestions!.getCurrentText());
}
// private but used in test
handleEmojiSelect(e: CustomEvent<ItemSelectedEvent>) {
if (e.detail.selected?.dataset['value']) {
this.setEmoji(e.detail.selected?.dataset['value']);
}
}
private 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();
}
private 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.
* private but used in test
*/
updateCaratPosition() {
this.hideEmojiAutocomplete = 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.
* private but used in test
*/
onValueChanged(e: BindValueChangeEvent) {
// Relay the event.
fire(this, 'bind-value-changed', {value: e.detail.value});
// 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
);
this.determineSuggestions(this.currentSearchString);
// 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();
}
private openEmojiDropdown() {
this.emojiSuggestions!.open();
this.reporting.reportInteraction('open-emoji-dropdown');
}
// private but used in test
formatSuggestions(matchedSuggestions: EmojiSuggestion[]) {
const suggestions = [];
for (const suggestion of matchedSuggestions) {
suggestion.dataValue = suggestion.value;
suggestion.text = `${suggestion.value} ${suggestion.match}`;
suggestions.push(suggestion);
}
this.suggestions = suggestions;
}
// private but used in test
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;
}
}
// private but used in test
resetEmojiDropdown() {
// hide and reset the autocomplete dropdown.
this.requestUpdate();
this.currentSearchString = '';
this.hideEmojiAutocomplete = true;
this.closeDropdown();
this.colonIndex = null;
this.textarea!.textarea.focus();
}
private handleTextChanged(text: string) {
// This is a bit redundant, because the `text` property has `notify:true`,
// so whenever the `text` changes the component fires two identical events
// `text-changed` and `value-changed`.
this.dispatchEvent(
new CustomEvent('value-changed', {detail: {value: text}})
);
this.dispatchEvent(
new CustomEvent('text-changed', {detail: {value: text}})
);
}
private indent(e: KeyboardEvent): void {
if (!document.queryCommandSupported('insertText')) {
return;
}
// When nothing is selected, selectionStart is the caret position. We want
// the indentation level of the current line, not the end of the text which
// may be different.
const currentLine = this.textarea!.textarea.value.substr(
0,
this.textarea!.selectionStart
)
.split('\n')
.pop();
const currentLineIndentation = currentLine?.match(/^\s*/)?.[0];
if (!currentLineIndentation) {
return;
}
// Stops the normal newline being added afterwards since we are adding it
// ourselves.
e.preventDefault();
// MDN says that execCommand is deprecated, but the replacements are still
// WIP (Input Events Level 2). The queryCommandSupported check should ensure
// that entering newlines will work even if this indent feature breaks.
// Directly replacing the text is possible, but would destroy the undo/redo
// queue.
document.execCommand('insertText', false, '\n' + currentLineIndentation);
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-textarea': GrTextarea;
}
}