diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/components/composer_text_input | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/src/components/composer_text_input')
3 files changed, 483 insertions, 0 deletions
diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.js b/addons/mail/static/src/components/composer_text_input/composer_text_input.js new file mode 100644 index 00000000..2bdd34da --- /dev/null +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.js @@ -0,0 +1,419 @@ +odoo.define('mail/static/src/components/composer_text_input/composer_text_input.js', function (require) { +'use strict'; + +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); + +const components = { + ComposerSuggestionList: require('mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js'), +}; +const { markEventHandled } = require('mail/static/src/utils/utils.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ComposerTextInput extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps({ + compareDepth: { + sendShortcuts: 1, + }, + }); + useStore(props => { + const composer = this.env.models['mail.composer'].get(props.composerLocalId); + const thread = composer && composer.thread; + return { + composerHasFocus: composer && composer.hasFocus, + composerHasSuggestions: composer && composer.hasSuggestions, + composerIsLog: composer && composer.isLog, + composerTextInputContent: composer && composer.textInputContent, + composerTextInputCursorEnd: composer && composer.textInputCursorEnd, + composerTextInputCursorStart: composer && composer.textInputCursorStart, + composerTextInputSelectionDirection: composer && composer.textInputSelectionDirection, + isDeviceMobile: this.env.messaging.device.isMobile, + threadModel: thread && thread.model, + }; + }); + /** + * Updates the composer text input content when composer is mounted + * as textarea content can't be changed from the DOM. + */ + useUpdate({ func: () => this._update() }); + /** + * Last content of textarea from input event. Useful to determine + * whether the current partner is typing something. + */ + this._textareaLastInputValue = ""; + /** + * Reference of the textarea. Useful to set height, selection and content. + */ + this._textareaRef = useRef('textarea'); + /** + * This is the invisible textarea used to compute the composer height + * based on the text content. We need it to downsize the textarea + * properly without flicker. + */ + this._mirroredTextareaRef = useRef('mirroredTextarea'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.composer} + */ + get composer() { + return this.env.models['mail.composer'].get(this.props.composerLocalId); + } + + /** + * @returns {string} + */ + get textareaPlaceholder() { + if (!this.composer) { + return ""; + } + if (this.composer.thread && this.composer.thread.model !== 'mail.channel') { + if (this.composer.isLog) { + return this.env._t("Log an internal note..."); + } + return this.env._t("Send a message to followers..."); + } + return this.env._t("Write something..."); + } + + focus() { + this._textareaRef.el.focus(); + } + + focusout() { + this.saveStateInStore(); + this._textareaRef.el.blur(); + } + + /** + * Saves the composer text input state in store + */ + saveStateInStore() { + this.composer.update({ + textInputContent: this._getContent(), + textInputCursorEnd: this._getSelectionEnd(), + textInputCursorStart: this._getSelectionStart(), + textInputSelectionDirection: this._textareaRef.el.selectionDirection, + }); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns textarea current content. + * + * @private + * @returns {string} + */ + _getContent() { + return this._textareaRef.el.value; + } + + /** + * Returns selection end position. + * + * @private + * @returns {integer} + */ + _getSelectionEnd() { + return this._textareaRef.el.selectionEnd; + } + + /** + * Returns selection start position. + * + * @private + * @returns {integer} + * + */ + _getSelectionStart() { + return this._textareaRef.el.selectionStart; + } + + /** + * Determines whether the textarea is empty or not. + * + * @private + * @returns {boolean} + */ + _isEmpty() { + return this._getContent() === ""; + } + + /** + * Updates the content and height of a textarea + * + * @private + */ + _update() { + if (!this.composer) { + return; + } + if (this.composer.isLastStateChangeProgrammatic) { + this._textareaRef.el.value = this.composer.textInputContent; + if (this.composer.hasFocus) { + this._textareaRef.el.setSelectionRange( + this.composer.textInputCursorStart, + this.composer.textInputCursorEnd, + this.composer.textInputSelectionDirection, + ); + } + this.composer.update({ isLastStateChangeProgrammatic: false }); + } + this._updateHeight(); + } + + /** + * Updates the textarea height. + * + * @private + */ + _updateHeight() { + this._mirroredTextareaRef.el.value = this.composer.textInputContent; + this._textareaRef.el.style.height = (this._mirroredTextareaRef.el.scrollHeight) + "px"; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickTextarea() { + // clicking might change the cursor position + this.saveStateInStore(); + } + + /** + * @private + */ + _onFocusinTextarea() { + this.composer.focus(); + this.trigger('o-focusin-composer'); + } + + /** + * @private + */ + _onFocusoutTextarea() { + this.saveStateInStore(); + this.composer.update({ hasFocus: false }); + } + + /** + * @private + */ + _onInputTextarea() { + this.saveStateInStore(); + if (this._textareaLastInputValue !== this._textareaRef.el.value) { + this.composer.handleCurrentPartnerIsTyping(); + } + this._textareaLastInputValue = this._textareaRef.el.value; + this._updateHeight(); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownTextarea(ev) { + switch (ev.key) { + case 'Escape': + if (this.composer.hasSuggestions) { + ev.preventDefault(); + this.composer.closeSuggestions(); + markEventHandled(ev, 'ComposerTextInput.closeSuggestions'); + } + break; + // UP, DOWN, TAB: prevent moving cursor if navigation in mention suggestions + case 'ArrowUp': + case 'PageUp': + case 'ArrowDown': + case 'PageDown': + case 'Home': + case 'End': + case 'Tab': + if (this.composer.hasSuggestions) { + // We use preventDefault here to avoid keys native actions but actions are handled in keyUp + ev.preventDefault(); + } + break; + // ENTER: submit the message only if the dropdown mention proposition is not displayed + case 'Enter': + this._onKeydownTextareaEnter(ev); + break; + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownTextareaEnter(ev) { + if (this.composer.hasSuggestions) { + ev.preventDefault(); + return; + } + if ( + this.props.sendShortcuts.includes('ctrl-enter') && + !ev.altKey && + ev.ctrlKey && + !ev.metaKey && + !ev.shiftKey + ) { + this.trigger('o-composer-text-input-send-shortcut'); + ev.preventDefault(); + return; + } + if ( + this.props.sendShortcuts.includes('enter') && + !ev.altKey && + !ev.ctrlKey && + !ev.metaKey && + !ev.shiftKey + ) { + this.trigger('o-composer-text-input-send-shortcut'); + ev.preventDefault(); + return; + } + if ( + this.props.sendShortcuts.includes('meta-enter') && + !ev.altKey && + !ev.ctrlKey && + ev.metaKey && + !ev.shiftKey + ) { + this.trigger('o-composer-text-input-send-shortcut'); + ev.preventDefault(); + return; + } + } + + /** + * Key events management is performed in a Keyup to avoid intempestive RPC calls + * + * @private + * @param {KeyboardEvent} ev + */ + _onKeyupTextarea(ev) { + switch (ev.key) { + case 'Escape': + // already handled in _onKeydownTextarea, break to avoid default + break; + // ENTER, HOME, END, UP, DOWN, PAGE UP, PAGE DOWN, TAB: check if navigation in mention suggestions + case 'Enter': + if (this.composer.hasSuggestions) { + this.composer.insertSuggestion(); + this.composer.closeSuggestions(); + this.focus(); + } + break; + case 'ArrowUp': + case 'PageUp': + if (this.composer.hasSuggestions) { + this.composer.setPreviousSuggestionActive(); + this.composer.update({ hasToScrollToActiveSuggestion: true }); + } + break; + case 'ArrowDown': + case 'PageDown': + if (this.composer.hasSuggestions) { + this.composer.setNextSuggestionActive(); + this.composer.update({ hasToScrollToActiveSuggestion: true }); + } + break; + case 'Home': + if (this.composer.hasSuggestions) { + this.composer.setFirstSuggestionActive(); + this.composer.update({ hasToScrollToActiveSuggestion: true }); + } + break; + case 'End': + if (this.composer.hasSuggestions) { + this.composer.setLastSuggestionActive(); + this.composer.update({ hasToScrollToActiveSuggestion: true }); + } + break; + case 'Tab': + if (this.composer.hasSuggestions) { + if (ev.shiftKey) { + this.composer.setPreviousSuggestionActive(); + this.composer.update({ hasToScrollToActiveSuggestion: true }); + } else { + this.composer.setNextSuggestionActive(); + this.composer.update({ hasToScrollToActiveSuggestion: true }); + } + } + break; + case 'Alt': + case 'AltGraph': + case 'CapsLock': + case 'Control': + case 'Fn': + case 'FnLock': + case 'Hyper': + case 'Meta': + case 'NumLock': + case 'ScrollLock': + case 'Shift': + case 'ShiftSuper': + case 'Symbol': + case 'SymbolLock': + // prevent modifier keys from resetting the suggestion state + break; + // Otherwise, check if a mention is typed + default: + this.saveStateInStore(); + } + } + +} + +Object.assign(ComposerTextInput, { + components, + defaultProps: { + hasMentionSuggestionsBelowPosition: false, + sendShortcuts: [], + }, + props: { + composerLocalId: String, + hasMentionSuggestionsBelowPosition: Boolean, + isCompact: Boolean, + /** + * Keyboard shortcuts from text input to send message. + */ + sendShortcuts: { + type: Array, + element: String, + validate: prop => { + for (const shortcut of prop) { + if (!['ctrl-enter', 'enter', 'meta-enter'].includes(shortcut)) { + return false; + } + } + return true; + }, + }, + }, + template: 'mail.ComposerTextInput', +}); + +return ComposerTextInput; + +}); diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.scss b/addons/mail/static/src/components/composer_text_input/composer_text_input.scss new file mode 100644 index 00000000..b9119a71 --- /dev/null +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.scss @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ComposerTextInput { + min-width: 0; + position: relative; +} + +.o_ComposerTextInput_mirroredTextarea { + height: 0; + position: absolute; + opacity: 0; + overflow: hidden; + top: -10000px; +} + +.o_ComposerTextInput_textareaStyle { + padding: 10px; + resize: none; + border-radius: $o-mail-rounded-rectangle-border-radius-lg; + border: none; + overflow: auto; + + &.o-composer-is-compact { + // When composer is compact, textarea should not be rounded on the right as + // buttons are glued to it + border-top-right-radius: 0; + border-bottom-right-radius: 0; + // Chat window height should be taken into account to choose this value + // ideally this should be less than the third of chat window height + max-height: 100px; + } + + &:not(.o-composer-is-compact) { + // Don't allow the input to take the whole height when it's not compact + // (like in chatter for example) but allow it to take some more place + max-height: 400px; + } +} diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.xml b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml new file mode 100644 index 00000000..a14fdee8 --- /dev/null +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ComposerTextInput" owl="1"> + <div class="o_ComposerTextInput"> + <t t-if="composer"> + <t t-if="composer.hasSuggestions"> + <ComposerSuggestionList + composerLocalId="props.composerLocalId" + isBelow="props.hasMentionSuggestionsBelowPosition" + /> + </t> + <textarea class="o_ComposerTextInput_textarea o_ComposerTextInput_textareaStyle" t-att-class="{ 'o-composer-is-compact': props.isCompact }" t-esc="composer.textInputContent" t-att-placeholder="textareaPlaceholder" t-on-click="_onClickTextarea" t-on-focusin="_onFocusinTextarea" t-on-focusout="_onFocusoutTextarea" t-on-keydown="_onKeydownTextarea" t-on-keyup="_onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/> + <!-- + This is an invisible textarea used to compute the composer + height based on the text content. We need it to downsize + the textarea properly without flicker. + --> + <textarea class="o_ComposerTextInput_mirroredTextarea o_ComposerTextInput_textareaStyle" t-att-class="{ 'o-composer-is-compact': props.isCompact }" t-esc="composer.textInputContent" t-ref="mirroredTextarea" disabled="1"/> + </t> + </div> + </t> + +</templates> |
