mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-11-03 20:48:07 +00:00 
			
		
		
		
	feat: Adds support for selecting emojis using the keyboard (#10055)
This commit is contained in:
		@@ -17,6 +17,8 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
 | 
			
		||||
import TagAgents from '../conversation/TagAgents.vue';
 | 
			
		||||
import CannedResponse from '../conversation/CannedResponse.vue';
 | 
			
		||||
import VariableList from '../conversation/VariableList.vue';
 | 
			
		||||
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  appendSignature,
 | 
			
		||||
  removeSignature,
 | 
			
		||||
@@ -24,6 +26,7 @@ import {
 | 
			
		||||
  scrollCursorIntoView,
 | 
			
		||||
  findNodeToInsertImage,
 | 
			
		||||
  setURLWithQueryAndSize,
 | 
			
		||||
  getContentNode,
 | 
			
		||||
} from 'dashboard/helper/editorHelper';
 | 
			
		||||
 | 
			
		||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
 | 
			
		||||
@@ -35,10 +38,8 @@ import {
 | 
			
		||||
} from 'shared/helpers/KeyboardHelpers';
 | 
			
		||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
 | 
			
		||||
import { useUISettings } from 'dashboard/composables/useUISettings';
 | 
			
		||||
import {
 | 
			
		||||
  replaceVariablesInMessage,
 | 
			
		||||
  createTypingIndicator,
 | 
			
		||||
} from '@chatwoot/utils';
 | 
			
		||||
 | 
			
		||||
import { createTypingIndicator } from '@chatwoot/utils';
 | 
			
		||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
 | 
			
		||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
 | 
			
		||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
 | 
			
		||||
@@ -71,7 +72,12 @@ const createState = (
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'WootMessageEditor',
 | 
			
		||||
  components: { TagAgents, CannedResponse, VariableList },
 | 
			
		||||
  components: {
 | 
			
		||||
    TagAgents,
 | 
			
		||||
    CannedResponse,
 | 
			
		||||
    VariableList,
 | 
			
		||||
    KeyboardEmojiSelector,
 | 
			
		||||
  },
 | 
			
		||||
  mixins: [keyboardEventListenerMixins],
 | 
			
		||||
  props: {
 | 
			
		||||
    value: { type: String, default: '' },
 | 
			
		||||
@@ -119,9 +125,11 @@ export default {
 | 
			
		||||
      showUserMentions: false,
 | 
			
		||||
      showCannedMenu: false,
 | 
			
		||||
      showVariables: false,
 | 
			
		||||
      showEmojiMenu: false,
 | 
			
		||||
      mentionSearchKey: '',
 | 
			
		||||
      cannedSearchTerm: '',
 | 
			
		||||
      variableSearchTerm: '',
 | 
			
		||||
      emojiSearchTerm: '',
 | 
			
		||||
      editorView: null,
 | 
			
		||||
      range: null,
 | 
			
		||||
      state: undefined,
 | 
			
		||||
@@ -169,7 +177,7 @@ export default {
 | 
			
		||||
            this.editorView = args.view;
 | 
			
		||||
            this.range = args.range;
 | 
			
		||||
 | 
			
		||||
            this.mentionSearchKey = args.text.replace('@', '');
 | 
			
		||||
            this.mentionSearchKey = args.text;
 | 
			
		||||
 | 
			
		||||
            return false;
 | 
			
		||||
          },
 | 
			
		||||
@@ -198,7 +206,7 @@ export default {
 | 
			
		||||
            this.editorView = args.view;
 | 
			
		||||
            this.range = args.range;
 | 
			
		||||
 | 
			
		||||
            this.cannedSearchTerm = args.text.replace('/', '');
 | 
			
		||||
            this.cannedSearchTerm = args.text;
 | 
			
		||||
            return false;
 | 
			
		||||
          },
 | 
			
		||||
          onExit: () => {
 | 
			
		||||
@@ -226,7 +234,7 @@ export default {
 | 
			
		||||
            this.editorView = args.view;
 | 
			
		||||
            this.range = args.range;
 | 
			
		||||
 | 
			
		||||
            this.variableSearchTerm = args.text.replace('{{', '');
 | 
			
		||||
            this.variableSearchTerm = args.text;
 | 
			
		||||
            return false;
 | 
			
		||||
          },
 | 
			
		||||
          onExit: () => {
 | 
			
		||||
@@ -238,6 +246,31 @@ export default {
 | 
			
		||||
            return event.keyCode === 13 && this.showVariables;
 | 
			
		||||
          },
 | 
			
		||||
        }),
 | 
			
		||||
        suggestionsPlugin({
 | 
			
		||||
          matcher: triggerCharacters(':', 1), // Trigger after ':' and at least 1 characters
 | 
			
		||||
          suggestionClass: '',
 | 
			
		||||
          onEnter: args => {
 | 
			
		||||
            this.showEmojiMenu = true;
 | 
			
		||||
            this.emojiSearchTerm = args.text || '';
 | 
			
		||||
            this.range = args.range;
 | 
			
		||||
            this.editorView = args.view;
 | 
			
		||||
            return false;
 | 
			
		||||
          },
 | 
			
		||||
          onChange: args => {
 | 
			
		||||
            this.editorView = args.view;
 | 
			
		||||
            this.range = args.range;
 | 
			
		||||
            this.emojiSearchTerm = args.text;
 | 
			
		||||
            return false;
 | 
			
		||||
          },
 | 
			
		||||
          onExit: () => {
 | 
			
		||||
            this.emojiSearchTerm = '';
 | 
			
		||||
            this.showEmojiMenu = false;
 | 
			
		||||
            return false;
 | 
			
		||||
          },
 | 
			
		||||
          onKeyDown: ({ event }) => {
 | 
			
		||||
            return event.keyCode === 13 && this.showEmojiMenu;
 | 
			
		||||
          },
 | 
			
		||||
        }),
 | 
			
		||||
      ];
 | 
			
		||||
    },
 | 
			
		||||
    sendWithSignature() {
 | 
			
		||||
@@ -267,6 +300,8 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    editorId() {
 | 
			
		||||
      this.showCannedMenu = false;
 | 
			
		||||
      this.showEmojiMenu = false;
 | 
			
		||||
      this.showVariables = false;
 | 
			
		||||
      this.cannedSearchTerm = '';
 | 
			
		||||
      this.reloadState(this.value);
 | 
			
		||||
    },
 | 
			
		||||
@@ -517,57 +552,36 @@ export default {
 | 
			
		||||
      this.editorView.dispatch(tr.setSelection(selection));
 | 
			
		||||
      this.editorView.focus();
 | 
			
		||||
    },
 | 
			
		||||
    insertMentionNode(mentionItem) {
 | 
			
		||||
    /**
 | 
			
		||||
     * Inserts special content (mention, canned response, variable, emoji) into the editor.
 | 
			
		||||
     * @param {string} type - The type of special content to insert. Possible values: 'mention', 'canned_response', 'variable', 'emoji'.
 | 
			
		||||
     * @param {Object|string} content - The content to insert, depending on the type.
 | 
			
		||||
     */
 | 
			
		||||
    insertSpecialContent(type, content) {
 | 
			
		||||
      if (!this.editorView) {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
      const node = this.editorView.state.schema.nodes.mention.create({
 | 
			
		||||
        userId: mentionItem.id,
 | 
			
		||||
        userFullName: mentionItem.name,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      this.insertNodeIntoEditor(node, this.range.from, this.range.to);
 | 
			
		||||
      this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
 | 
			
		||||
 | 
			
		||||
      return false;
 | 
			
		||||
    },
 | 
			
		||||
    insertCannedResponse(cannedItem) {
 | 
			
		||||
      const updatedMessage = replaceVariablesInMessage({
 | 
			
		||||
        message: cannedItem,
 | 
			
		||||
        variables: this.variables,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (!this.editorView) {
 | 
			
		||||
        return null;
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      let node = new MessageMarkdownTransformer(messageSchema).parse(
 | 
			
		||||
        updatedMessage
 | 
			
		||||
      let { node, from, to } = getContentNode(
 | 
			
		||||
        this.editorView,
 | 
			
		||||
        type,
 | 
			
		||||
        content,
 | 
			
		||||
        this.range,
 | 
			
		||||
        this.variables
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      const from =
 | 
			
		||||
        node.textContent === updatedMessage
 | 
			
		||||
          ? this.range.from
 | 
			
		||||
          : this.range.from - 1;
 | 
			
		||||
 | 
			
		||||
      this.insertNodeIntoEditor(node, from, this.range.to);
 | 
			
		||||
 | 
			
		||||
      this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
 | 
			
		||||
      return false;
 | 
			
		||||
    },
 | 
			
		||||
    insertVariable(variable) {
 | 
			
		||||
      if (!this.editorView) {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const content = `{{${variable}}}`;
 | 
			
		||||
      let node = this.editorView.state.schema.text(content);
 | 
			
		||||
      const { from, to } = this.range;
 | 
			
		||||
      if (!node) return;
 | 
			
		||||
 | 
			
		||||
      this.insertNodeIntoEditor(node, from, to);
 | 
			
		||||
      this.showVariables = false;
 | 
			
		||||
      this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
 | 
			
		||||
      return false;
 | 
			
		||||
 | 
			
		||||
      const event_map = {
 | 
			
		||||
        mention: CONVERSATION_EVENTS.USED_MENTIONS,
 | 
			
		||||
        cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
 | 
			
		||||
        variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
 | 
			
		||||
        emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      this.$track(event_map[type]);
 | 
			
		||||
    },
 | 
			
		||||
    openFileBrowser() {
 | 
			
		||||
      this.$refs.imageUpload.click();
 | 
			
		||||
@@ -687,17 +701,22 @@ export default {
 | 
			
		||||
    <TagAgents
 | 
			
		||||
      v-if="showUserMentions && isPrivate"
 | 
			
		||||
      :search-key="mentionSearchKey"
 | 
			
		||||
      @click="insertMentionNode"
 | 
			
		||||
      @click="content => insertSpecialContent('mention', content)"
 | 
			
		||||
    />
 | 
			
		||||
    <CannedResponse
 | 
			
		||||
      v-if="shouldShowCannedResponses"
 | 
			
		||||
      :search-key="cannedSearchTerm"
 | 
			
		||||
      @click="insertCannedResponse"
 | 
			
		||||
      @click="content => insertSpecialContent('cannedResponse', content)"
 | 
			
		||||
    />
 | 
			
		||||
    <VariableList
 | 
			
		||||
      v-if="shouldShowVariables"
 | 
			
		||||
      :search-key="variableSearchTerm"
 | 
			
		||||
      @click="insertVariable"
 | 
			
		||||
      @click="content => insertSpecialContent('variable', content)"
 | 
			
		||||
    />
 | 
			
		||||
    <KeyboardEmojiSelector
 | 
			
		||||
      v-if="showEmojiMenu"
 | 
			
		||||
      :search-key="emojiSearchTerm"
 | 
			
		||||
      @click="emoji => insertSpecialContent('emoji', emoji)"
 | 
			
		||||
    />
 | 
			
		||||
    <input
 | 
			
		||||
      ref="imageUpload"
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,68 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { shallowRef, computed, onMounted } from 'vue';
 | 
			
		||||
import emojis from 'shared/components/emoji/emojisGroup.json';
 | 
			
		||||
import MentionBox from '../mentions/MentionBox.vue';
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  searchKey: {
 | 
			
		||||
    type: String,
 | 
			
		||||
    default: '',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(['click']);
 | 
			
		||||
 | 
			
		||||
const allEmojis = shallowRef([]);
 | 
			
		||||
 | 
			
		||||
const items = computed(() => {
 | 
			
		||||
  if (!props.searchKey) return [];
 | 
			
		||||
  const searchTerm = props.searchKey.toLowerCase();
 | 
			
		||||
  return allEmojis.value.filter(emoji =>
 | 
			
		||||
    emoji.searchString.includes(searchTerm)
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function loadEmojis() {
 | 
			
		||||
  allEmojis.value = emojis.flatMap(group =>
 | 
			
		||||
    group.emojis.map(emoji => ({
 | 
			
		||||
      ...emoji,
 | 
			
		||||
      searchString: `${emoji.slug} ${emoji.name}`.toLowerCase(),
 | 
			
		||||
    }))
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function handleMentionClick(item = {}) {
 | 
			
		||||
  emit('click', item.emoji);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  loadEmojis();
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
 | 
			
		||||
<template>
 | 
			
		||||
  <MentionBox
 | 
			
		||||
    v-if="items.length"
 | 
			
		||||
    type="emoji"
 | 
			
		||||
    :items="items"
 | 
			
		||||
    @mentionSelect="handleMentionClick"
 | 
			
		||||
  >
 | 
			
		||||
    <template #default="{ item, selected }">
 | 
			
		||||
      <span
 | 
			
		||||
        class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 truncate"
 | 
			
		||||
      >
 | 
			
		||||
        {{ item.emoji }}
 | 
			
		||||
        <p
 | 
			
		||||
          class="relative mb-0 truncate bottom-px"
 | 
			
		||||
          :class="{
 | 
			
		||||
            'text-woot-500 dark:text-woot-500': selected,
 | 
			
		||||
            'font-normal': !selected,
 | 
			
		||||
          }"
 | 
			
		||||
        >
 | 
			
		||||
          :{{ item.slug }}
 | 
			
		||||
        </p>
 | 
			
		||||
      </span>
 | 
			
		||||
    </template>
 | 
			
		||||
  </MentionBox>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -41,14 +41,11 @@ export default {
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
 | 
			
		||||
<template>
 | 
			
		||||
  <MentionBox
 | 
			
		||||
    v-if="items.length"
 | 
			
		||||
    :items="items"
 | 
			
		||||
    @mentionSelect="handleMentionClick"
 | 
			
		||||
  >
 | 
			
		||||
    <template slot-scope="{ item }">
 | 
			
		||||
      <strong>{{ item.label }}</strong> - {{ item.description }}
 | 
			
		||||
    </template>
 | 
			
		||||
  </MentionBox>
 | 
			
		||||
  />
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -56,20 +56,14 @@ export default {
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
 | 
			
		||||
<template>
 | 
			
		||||
  <MentionBox
 | 
			
		||||
    v-if="items.length"
 | 
			
		||||
    type="variable"
 | 
			
		||||
    :items="items"
 | 
			
		||||
    @mentionSelect="handleVariableClick"
 | 
			
		||||
  >
 | 
			
		||||
    <template slot-scope="{ item }">
 | 
			
		||||
      <span class="text-capitalize variable--list-label">
 | 
			
		||||
        {{ item.description }}
 | 
			
		||||
      </span>
 | 
			
		||||
      ({{ item.label }})
 | 
			
		||||
    </template>
 | 
			
		||||
  </MentionBox>
 | 
			
		||||
  />
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
 
 | 
			
		||||
@@ -90,22 +90,24 @@ const variableKey = (item = {}) => {
 | 
			
		||||
          }"
 | 
			
		||||
          @click="onListItemSelection(index)"
 | 
			
		||||
        >
 | 
			
		||||
          <p
 | 
			
		||||
            class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
 | 
			
		||||
            :class="{
 | 
			
		||||
              'text-woot-500 dark:text-woot-500': index === selectedIndex,
 | 
			
		||||
            }"
 | 
			
		||||
          >
 | 
			
		||||
            {{ item.description }}
 | 
			
		||||
          </p>
 | 
			
		||||
          <p
 | 
			
		||||
            class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
 | 
			
		||||
            :class="{
 | 
			
		||||
              'text-woot-500 dark:text-woot-500': index === selectedIndex,
 | 
			
		||||
            }"
 | 
			
		||||
          >
 | 
			
		||||
            {{ variableKey(item) }}
 | 
			
		||||
          </p>
 | 
			
		||||
          <slot :item="item" :index="index" :selected="index === selectedIndex">
 | 
			
		||||
            <p
 | 
			
		||||
              class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
 | 
			
		||||
              :class="{
 | 
			
		||||
                'text-woot-500 dark:text-woot-500': index === selectedIndex,
 | 
			
		||||
              }"
 | 
			
		||||
            >
 | 
			
		||||
              {{ item.description }}
 | 
			
		||||
            </p>
 | 
			
		||||
            <p
 | 
			
		||||
              class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
 | 
			
		||||
              :class="{
 | 
			
		||||
                'text-woot-500 dark:text-woot-500': index === selectedIndex,
 | 
			
		||||
              }"
 | 
			
		||||
            >
 | 
			
		||||
              {{ variableKey(item) }}
 | 
			
		||||
            </p>
 | 
			
		||||
          </slot>
 | 
			
		||||
        </button>
 | 
			
		||||
      </woot-dropdown-item>
 | 
			
		||||
    </ul>
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
 | 
			
		||||
  INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
 | 
			
		||||
  TRANSLATE_A_MESSAGE: 'Translated a message',
 | 
			
		||||
  INSERTED_A_VARIABLE: 'Inserted a variable',
 | 
			
		||||
  INSERTED_AN_EMOJI: 'Inserted an emoji',
 | 
			
		||||
  USED_MENTIONS: 'Used mentions',
 | 
			
		||||
  SEARCH_CONVERSATION: 'Searched conversations',
 | 
			
		||||
  APPLY_FILTER: 'Applied filters in the conversation list',
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,8 @@ import {
 | 
			
		||||
  MessageMarkdownTransformer,
 | 
			
		||||
  MessageMarkdownSerializer,
 | 
			
		||||
} from '@chatwoot/prosemirror-schema';
 | 
			
		||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
 | 
			
		||||
 | 
			
		||||
import * as Sentry from '@sentry/browser';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -281,3 +283,92 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Content Node Creation Helper Functions for
 | 
			
		||||
 * - mention
 | 
			
		||||
 * - canned response
 | 
			
		||||
 * - variable
 | 
			
		||||
 * - emoji
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Centralized node creation function that handles the creation of different types of nodes based on the specified type.
 | 
			
		||||
 * @param {Object} editorView - The editor view instance.
 | 
			
		||||
 * @param {string} nodeType - The type of node to create ('mention', 'cannedResponse', 'variable', 'emoji').
 | 
			
		||||
 * @param {Object|string} content - The content needed to create the node, which varies based on node type.
 | 
			
		||||
 * @returns {Object|null} - The created ProseMirror node or null if the type is not supported.
 | 
			
		||||
 */
 | 
			
		||||
const createNode = (editorView, nodeType, content) => {
 | 
			
		||||
  const { state } = editorView;
 | 
			
		||||
  switch (nodeType) {
 | 
			
		||||
    case 'mention':
 | 
			
		||||
      return state.schema.nodes.mention.create({
 | 
			
		||||
        userId: content.id,
 | 
			
		||||
        userFullName: content.name,
 | 
			
		||||
      });
 | 
			
		||||
    case 'cannedResponse':
 | 
			
		||||
      return new MessageMarkdownTransformer(messageSchema).parse(content);
 | 
			
		||||
    case 'variable':
 | 
			
		||||
      return state.schema.text(`{{${content}}}`);
 | 
			
		||||
    case 'emoji':
 | 
			
		||||
      return state.schema.text(content);
 | 
			
		||||
    default:
 | 
			
		||||
      return null;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Object mapping types to their respective node creation functions.
 | 
			
		||||
 */
 | 
			
		||||
const nodeCreators = {
 | 
			
		||||
  mention: (editorView, content, from, to) => ({
 | 
			
		||||
    node: createNode(editorView, 'mention', content),
 | 
			
		||||
    from,
 | 
			
		||||
    to,
 | 
			
		||||
  }),
 | 
			
		||||
  cannedResponse: (editorView, content, from, to, variables) => {
 | 
			
		||||
    const updatedMessage = replaceVariablesInMessage({
 | 
			
		||||
      message: content,
 | 
			
		||||
      variables,
 | 
			
		||||
    });
 | 
			
		||||
    const node = createNode(editorView, 'cannedResponse', updatedMessage);
 | 
			
		||||
    return {
 | 
			
		||||
      node,
 | 
			
		||||
      from: node.textContent === updatedMessage ? from : from - 1,
 | 
			
		||||
      to,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  variable: (editorView, content, from, to) => ({
 | 
			
		||||
    node: createNode(editorView, 'variable', content),
 | 
			
		||||
    from,
 | 
			
		||||
    to,
 | 
			
		||||
  }),
 | 
			
		||||
  emoji: (editorView, content, from, to) => ({
 | 
			
		||||
    node: createNode(editorView, 'emoji', content),
 | 
			
		||||
    from,
 | 
			
		||||
    to,
 | 
			
		||||
  }),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Retrieves a content node based on the specified type and content, using a functional approach to select the appropriate node creation function.
 | 
			
		||||
 * @param {Object} editorView - The editor view instance.
 | 
			
		||||
 * @param {string} type - The type of content node to create ('mention', 'cannedResponse', 'variable', 'emoji').
 | 
			
		||||
 * @param {string|Object} content - The content to be transformed into a node.
 | 
			
		||||
 * @param {Object} range - An object containing 'from' and 'to' properties indicating the range in the document where the node should be placed.
 | 
			
		||||
 * @param {Object} variables - Optional. Variables to replace in the content, used for 'cannedResponse' type.
 | 
			
		||||
 * @returns {Object} - An object containing the created node and the updated 'from' and 'to' positions.
 | 
			
		||||
 */
 | 
			
		||||
export const getContentNode = (
 | 
			
		||||
  editorView,
 | 
			
		||||
  type,
 | 
			
		||||
  content,
 | 
			
		||||
  { from, to },
 | 
			
		||||
  variables
 | 
			
		||||
) => {
 | 
			
		||||
  const creator = nodeCreators[type];
 | 
			
		||||
  return creator
 | 
			
		||||
    ? creator(editorView, content, from, to, variables)
 | 
			
		||||
    : { node: null, from, to };
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,127 @@
 | 
			
		||||
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
 | 
			
		||||
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
 | 
			
		||||
import { getContentNode } from '../editorHelper';
 | 
			
		||||
import {
 | 
			
		||||
  MessageMarkdownTransformer,
 | 
			
		||||
  messageSchema,
 | 
			
		||||
} from '@chatwoot/prosemirror-schema';
 | 
			
		||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
 | 
			
		||||
 | 
			
		||||
vi.mock('@chatwoot/prosemirror-schema', () => ({
 | 
			
		||||
  MessageMarkdownTransformer: vi.fn(),
 | 
			
		||||
  messageSchema: {},
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
vi.mock('@chatwoot/utils', () => ({
 | 
			
		||||
  replaceVariablesInMessage: vi.fn(),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
describe('getContentNode', () => {
 | 
			
		||||
  let editorView;
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    editorView = {
 | 
			
		||||
      state: {
 | 
			
		||||
        schema: {
 | 
			
		||||
          nodes: {
 | 
			
		||||
            mention: {
 | 
			
		||||
              create: vi.fn(),
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
          text: vi.fn(),
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    };
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getMentionNode', () => {
 | 
			
		||||
    it('should create a mention node', () => {
 | 
			
		||||
      const content = { id: 1, name: 'John Doe' };
 | 
			
		||||
      const from = 0;
 | 
			
		||||
      const to = 10;
 | 
			
		||||
      getContentNode(editorView, 'mention', content, {
 | 
			
		||||
        from,
 | 
			
		||||
        to,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(editorView.state.schema.nodes.mention.create).toHaveBeenCalledWith(
 | 
			
		||||
        {
 | 
			
		||||
          userId: content.id,
 | 
			
		||||
          userFullName: content.name,
 | 
			
		||||
        }
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getCannedResponseNode', () => {
 | 
			
		||||
    it('should create a canned response node', () => {
 | 
			
		||||
      const content = 'Hello {{name}}';
 | 
			
		||||
      const variables = { name: 'John' };
 | 
			
		||||
      const from = 0;
 | 
			
		||||
      const to = 10;
 | 
			
		||||
      const updatedMessage = 'Hello John';
 | 
			
		||||
 | 
			
		||||
      replaceVariablesInMessage.mockReturnValue(updatedMessage);
 | 
			
		||||
      MessageMarkdownTransformer.mockImplementation(() => ({
 | 
			
		||||
        parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
 | 
			
		||||
      }));
 | 
			
		||||
 | 
			
		||||
      const { node } = getContentNode(
 | 
			
		||||
        editorView,
 | 
			
		||||
        'cannedResponse',
 | 
			
		||||
        content,
 | 
			
		||||
        { from, to },
 | 
			
		||||
        variables
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      expect(replaceVariablesInMessage).toHaveBeenCalledWith({
 | 
			
		||||
        message: content,
 | 
			
		||||
        variables,
 | 
			
		||||
      });
 | 
			
		||||
      expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
 | 
			
		||||
      expect(node.textContent).toBe(updatedMessage);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getVariableNode', () => {
 | 
			
		||||
    it('should create a variable node', () => {
 | 
			
		||||
      const content = 'name';
 | 
			
		||||
      const from = 0;
 | 
			
		||||
      const to = 10;
 | 
			
		||||
      getContentNode(editorView, 'variable', content, {
 | 
			
		||||
        from,
 | 
			
		||||
        to,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getEmojiNode', () => {
 | 
			
		||||
    it('should create an emoji node', () => {
 | 
			
		||||
      const content = '😊';
 | 
			
		||||
      const from = 0;
 | 
			
		||||
      const to = 2;
 | 
			
		||||
      getContentNode(editorView, 'emoji', content, {
 | 
			
		||||
        from,
 | 
			
		||||
        to,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(editorView.state.schema.text).toHaveBeenCalledWith('😊');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getContentNode', () => {
 | 
			
		||||
    it('should return null for invalid type', () => {
 | 
			
		||||
      const content = 'invalid';
 | 
			
		||||
      const from = 0;
 | 
			
		||||
      const to = 10;
 | 
			
		||||
      const { node } = getContentNode(editorView, 'invalid', content, {
 | 
			
		||||
        from,
 | 
			
		||||
        to,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(node).toBeNull();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -31,7 +31,7 @@
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@braid/vue-formulate": "^2.5.2",
 | 
			
		||||
    "@chatwoot/ninja-keys": "1.2.3",
 | 
			
		||||
    "@chatwoot/prosemirror-schema": "1.0.11",
 | 
			
		||||
    "@chatwoot/prosemirror-schema": "1.0.13",
 | 
			
		||||
    "@chatwoot/utils": "^0.0.25",
 | 
			
		||||
    "@hcaptcha/vue-hcaptcha": "^0.3.2",
 | 
			
		||||
    "@june-so/analytics-next": "^2.0.0",
 | 
			
		||||
 
 | 
			
		||||
@@ -2914,10 +2914,10 @@
 | 
			
		||||
    hotkeys-js "3.8.7"
 | 
			
		||||
    lit "2.2.6"
 | 
			
		||||
 | 
			
		||||
"@chatwoot/prosemirror-schema@1.0.11":
 | 
			
		||||
  version "1.0.11"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.11.tgz#b66201be8b09cd4c6370a60607cc5d4f9d924bdb"
 | 
			
		||||
  integrity sha512-OAUa1CuHtetEPh/ZhRkMLVQHY2/eesCRb2IRdVzjh0z+4spp3fG826y8FbOEsa7m4hisNirnGF/mDH+NMmYetw==
 | 
			
		||||
"@chatwoot/prosemirror-schema@1.0.13":
 | 
			
		||||
  version "1.0.13"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.13.tgz#1b7cd82e16bd66d8fcd96bc3415b56e7e40841a0"
 | 
			
		||||
  integrity sha512-Ki7H2kUxkbDup+A8useTRQb2F45BxdTe1C9VsX4oSRh3WHqWriedEMmpnZy1SCzRm0zEFCw57RA4XppgZVkfrg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    markdown-it-sup "^1.0.0"
 | 
			
		||||
    prosemirror-commands "^1.1.4"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user