summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/components/composer_text_input
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/components/composer_text_input
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/static/src/components/composer_text_input')
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.js419
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.scss40
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.xml24
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>