Files
chatwoot/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
2025-04-08 12:38:28 +05:30

830 lines
22 KiB
Vue

<script setup>
// TODO This is a huge component, we should split this up into separate composables
// like `useSignature`, `useImageHandling`, `useFileUpload`, `useSpecialContent``
import {
ref,
unref,
computed,
watch,
onMounted,
useTemplateRef,
nextTick,
} from 'vue';
import CannedResponse from '../conversation/CannedResponse.vue';
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
import VariableList from '../conversation/VariableList.vue';
import { useEmitter } from 'dashboard/composables/emitter';
import { useI18n } from 'vue-i18n';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
import {
messageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import {
appendSignature,
findNodeToInsertImage,
getContentNode,
insertAtCursor,
removeSignature as removeSignatureHelper,
scrollCursorIntoView,
setURLWithQueryAndSize,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter,
} from 'shared/helpers/KeyboardHelpers';
import { createTypingIndicator } from '@chatwoot/utils';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
const props = defineProps({
modelValue: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
isPrivate: { type: Boolean, default: false },
enableSuggestions: { type: Boolean, default: true },
overrideLineBreaks: { type: Boolean, default: false },
updateSelectionWith: { type: String, default: '' },
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
// allowSignature is a kill switch, ensuring no signature methods
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
});
const emit = defineEmits([
'typingOn',
'typingOff',
'toggleUserMention',
'toggleCannedMenu',
'toggleVariablesMenu',
'clearSelection',
'blur',
'focus',
'input',
'update:modelValue',
]);
const { t } = useI18n();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const createState = (
content,
placeholder,
plugins = [],
methods = {},
enabledMenuOptions = []
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
plugins: buildEditor({
schema: messageSchema,
placeholder,
methods,
plugins,
enabledMenuOptions,
}),
});
};
const { isEditorHotKeyEnabled, fetchSignatureFlagFromUISettings } =
useUISettings();
const typingIndicator = createTypingIndicator(
() => emit('typingOn'),
() => emit('typingOff'),
TYPING_INDICATOR_IDLE_TIME
);
// we don't need them to be reactive
// It cases weird issues where the objects are proxied
// and then the editor doesn't work as expected
// We have to wrap them in closures or use toRaw to get the actual values
let editorView = null;
let state = null;
const showUserMentions = ref(false);
const showCannedMenu = ref(false);
const showVariables = ref(false);
const showEmojiMenu = ref(false);
const mentionSearchKey = ref('');
const cannedSearchTerm = ref('');
const variableSearchTerm = ref('');
const emojiSearchTerm = ref('');
const range = ref(null);
const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref
const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
const contentFromEditor = () => {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
};
const shouldShowVariables = computed(() => {
return props.enableVariables && showVariables.value && !props.isPrivate;
});
const shouldShowCannedResponses = computed(() => {
return (
props.enableCannedResponses && showCannedMenu.value && !props.isPrivate
);
});
const editorMenuOptions = computed(() => {
return props.enabledMenuOptions.length
? props.enabledMenuOptions
: MESSAGE_EDITOR_MENU_OPTIONS;
});
function createSuggestionPlugin({
trigger,
minChars = 0,
showMenu,
searchTerm,
isAllowed = () => true,
}) {
return suggestionsPlugin({
matcher: triggerCharacters(trigger, minChars),
suggestionClass: '',
onEnter: args => {
if (!isAllowed()) return false;
showMenu.value = true;
range.value = args.range;
editorView = args.view;
if (searchTerm) searchTerm.value = args.text || '';
return false;
},
onChange: args => {
editorView = args.view;
range.value = args.range;
if (searchTerm) searchTerm.value = args.text;
return false;
},
onExit: () => {
if (searchTerm) searchTerm.value = '';
showMenu.value = false;
return false;
},
onKeyDown: ({ event }) => {
return event.keyCode === 13 && showMenu.value;
},
});
}
const plugins = computed(() => {
if (!props.enableSuggestions) {
return [];
}
return [
createSuggestionPlugin({
trigger: '@',
showMenu: showUserMentions,
searchTerm: mentionSearchKey,
}),
createSuggestionPlugin({
trigger: '/',
showMenu: showCannedMenu,
searchTerm: cannedSearchTerm,
isAllowed: () => !props.isPrivate,
}),
createSuggestionPlugin({
trigger: '{{',
showMenu: showVariables,
searchTerm: variableSearchTerm,
isAllowed: () => !props.isPrivate,
}),
createSuggestionPlugin({
trigger: ':',
minChars: 2,
showMenu: showEmojiMenu,
searchTerm: emojiSearchTerm,
}),
];
});
const sendWithSignature = computed(() => {
// this is considered the source of truth, we watch this property
// on change, we toggle the signature in the editor
if (props.allowSignature && !props.isPrivate && props.channelType) {
return fetchSignatureFlagFromUISettings(props.channelType);
}
return false;
});
watch(showUserMentions, updatedValue => {
emit('toggleUserMention', props.isPrivate && updatedValue);
});
watch(showCannedMenu, updatedValue => {
emit('toggleCannedMenu', !props.isPrivate && updatedValue);
});
watch(showVariables, updatedValue => {
emit('toggleVariablesMenu', !props.isPrivate && updatedValue);
});
function focusEditorInputField(pos = 'end') {
const { tr } = editorView.state;
const selection =
pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc);
editorView.dispatch(tr.setSelection(selection));
editorView.focus();
}
function isBodyEmpty(content) {
// if content is undefined, we assume that the body is empty
if (!content) return true;
// if the signature is present, we need to remove it before checking
// note that we don't update the editorView, so this is safe
const bodyWithoutSignature = props.signature
? removeSignatureHelper(content, props.signature)
: content;
// trimming should remove all the whitespaces, so we can check the length
return bodyWithoutSignature.trim().length === 0;
}
function handleEmptyBodyWithSignature() {
const { schema, tr } = state;
// create a paragraph node and
// start a transaction to append it at the end
const paragraph = schema.nodes.paragraph.create();
const paragraphTransaction = tr.insert(0, paragraph);
editorView.dispatch(paragraphTransaction);
// Set the focus at the start of the input field
focusEditorInputField('start');
}
function focusEditor(content) {
if (props.disabled) return;
const unrefContent = unref(content);
if (isBodyEmpty(unrefContent) && sendWithSignature.value) {
// reload state can be called when switching between conversations, or when drafts is loaded
// these drafts can also have a signature, so we need to check if the body is empty
// and handle things accordingly
handleEmptyBodyWithSignature();
} else if (props.focusOnMount) {
// this is in the else block, handleEmptyBodyWithSignature also has a call to the focus method
// the position is set to start, because the signature is added at the end of the body
focusEditorInputField('end');
}
}
function openFileBrowser() {
imageUpload.value.click();
}
function reloadState(content = props.modelValue) {
const unrefContent = unref(content);
state = createState(
unrefContent,
props.placeholder,
plugins.value,
{ onImageUpload: openFileBrowser },
editorMenuOptions.value
);
editorView.updateState(state);
focusEditor(unrefContent);
}
function addSignature() {
let content = props.modelValue;
// see if the content is empty, if it is before appending the signature
// we need to add a paragraph node and move the cursor at the start of the editor
const contentWasEmpty = isBodyEmpty(content);
content = appendSignature(content, props.signature);
// need to reload first, ensuring that the editorView is updated
reloadState(content);
if (contentWasEmpty) {
handleEmptyBodyWithSignature();
}
}
function removeSignature() {
if (!props.signature) return;
let content = props.modelValue;
content = removeSignatureHelper(content, props.signature);
// reload the state, ensuring that the editorView is updated
reloadState(content);
}
function toggleSignatureInEditor(signatureEnabled) {
// The toggleSignatureInEditor gets the new value from the
// watcher, this means that if the value is true, the signature
// is supposed to be added, else we remove it.
if (signatureEnabled) {
addSignature();
} else {
removeSignature();
}
}
function setToolbarPosition() {
const editorRect = editorRoot.value.getBoundingClientRect();
const rect = selectedImageNode.value.getBoundingClientRect();
toolbarPosition.value = {
top: `${rect.top - editorRect.top - 30}px`,
left: `${rect.left - editorRect.left - 4}px`,
};
}
function setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) {
return;
}
setURLWithQueryAndSize(selectedImageNode.value, size, editorView);
isImageNodeSelected.value = false;
}
function isEditorMouseFocusedOnAnImage() {
if (!props.showImageResizeToolbar) {
return;
}
selectedImageNode.value = document.querySelector(
'img.ProseMirror-selectednode'
);
if (selectedImageNode.value) {
isImageNodeSelected.value = !!selectedImageNode.value;
// Get the position of the selected node
setToolbarPosition();
} else {
isImageNodeSelected.value = false;
}
}
function emitOnChange() {
emit('input', contentFromEditor());
emit('update:modelValue', contentFromEditor());
}
function updateImgToolbarOnDelete() {
// check if the selected node is present or not on keyup
// this is needed because the user can select an image and then delete it
// in that case, the selected node will be null and we need to hide the toolbar
// otherwise, the toolbar will be visible even when the image is deleted and cause some errors
if (selectedImageNode.value) {
const hasImgSelectedNode = document.querySelector(
'img.ProseMirror-selectednode'
);
if (!hasImgSelectedNode) {
isImageNodeSelected.value = false;
}
}
}
function isEnterToSendEnabled() {
return isEditorHotKeyEnabled('enter');
}
function isCmdPlusEnterToSendEnabled() {
return isEditorHotKeyEnabled('cmd_enter');
}
useKeyboardEvents({
'Alt+KeyP': {
action: focusEditorInputField,
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: focusEditorInputField,
allowOnFocusedInput: true,
},
});
function onImageInsertInEditor(fileUrl) {
const { tr } = editorView.state;
const insertData = findNodeToInsertImage(editorView.state, fileUrl);
if (insertData) {
editorView.dispatch(
tr.insert(insertData.pos, insertData.node).scrollIntoView()
);
focusEditorInputField();
}
}
async function uploadImageToStorage(file) {
try {
const { fileUrl } = await uploadFile(file);
if (fileUrl) {
onImageInsertInEditor(fileUrl);
}
useAlert(
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS')
);
} catch (error) {
useAlert(
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_ERROR')
);
}
}
function onFileChange() {
const file = imageUpload.value.files[0];
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
uploadImageToStorage(file);
} else {
useAlert(
t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR',
{
size: MAXIMUM_FILE_UPLOAD_SIZE,
}
)
);
}
imageUpload.value = '';
}
function handleLineBreakWhenEnterToSendEnabled(event) {
if (
hasPressedEnterAndNotCmdOrShift(event) &&
isEnterToSendEnabled() &&
!props.overrideLineBreaks
) {
event.preventDefault();
}
}
async function insertNodeIntoEditor(node, from = 0, to = 0) {
state = insertAtCursor(editorView, node, from, to);
emitOnChange();
await nextTick();
scrollCursorIntoView(editorView);
}
function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
let node = new MessageMarkdownTransformer(messageSchema).parse(content);
insertNodeIntoEditor(node, from, undefined);
}
/**
* 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.
*/
function insertSpecialContent(type, content) {
if (!editorView) {
return;
}
let { node, from, to } = getContentNode(
editorView,
type,
content,
range.value,
props.variables
);
if (!node) return;
insertNodeIntoEditor(node, from, to);
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,
};
useTrack(event_map[type]);
}
function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
if (
hasPressedCommandAndEnter(event) &&
isCmdPlusEnterToSendEnabled() &&
!props.overrideLineBreaks
) {
event.preventDefault();
}
}
function onKeydown(event) {
if (isEnterToSendEnabled()) {
handleLineBreakWhenEnterToSendEnabled(event);
}
if (isCmdPlusEnterToSendEnabled()) {
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
}
}
function createEditorView() {
editorView = new EditorView(editor.value, {
state: state,
editable: () => !props.disabled,
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
if (tx.docChanged) {
emitOnChange();
}
},
handleDOMEvents: {
keyup: () => {
if (!props.disabled) {
typingIndicator.start();
updateImgToolbarOnDelete();
}
},
keydown: (view, event) => !props.disabled && onKeydown(event),
focus: () => !props.disabled && emit('focus'),
click: () => !props.disabled && isEditorMouseFocusedOnAnImage(),
blur: () => {
if (props.disabled) return;
typingIndicator.stop();
emit('blur');
},
paste: (_view, event) => {
if (props.disabled) return;
const data = event.clipboardData.files;
if (data.length > 0) {
event.preventDefault();
}
},
},
});
}
watch(
computed(() => props.modelValue),
(newVal = '') => {
if (newVal !== contentFromEditor()) {
reloadState(newVal);
}
}
);
watch(
computed(() => props.editorId),
() => {
showCannedMenu.value = false;
showEmojiMenu.value = false;
showVariables.value = false;
cannedSearchTerm.value = '';
reloadState(props.modelValue);
}
);
watch(
computed(() => props.isPrivate),
() => {
reloadState(props.modelValue);
}
);
watch(
computed(() => props.updateSelectionWith),
(newValue, oldValue) => {
if (!editorView) return;
if (newValue !== oldValue) {
if (props.updateSelectionWith !== '') {
const node = editorView.state.schema.text(props.updateSelectionWith);
const tr = editorView.state.tr.replaceSelectionWith(node);
editorView.focus();
state = editorView.state.apply(tr);
editorView.updateState(state);
emitOnChange();
emit('clearSelection');
}
}
}
);
watch(sendWithSignature, newValue => {
// see if the allowSignature flag is true
if (props.allowSignature) {
toggleSignatureInEditor(newValue);
}
});
onMounted(() => {
// [VITE] state assignment was done in created before
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
{ onImageUpload: openFileBrowser },
editorMenuOptions.value
);
createEditorView();
editorView.updateState(state);
if (props.focusOnMount) {
focusEditorInputField();
}
});
// BUS Event to insert text or markdown into the editor at the
// current cursor position.
// Components using this
// 1. SearchPopover.vue
useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
</script>
<template>
<div ref="editorRoot" class="relative w-full">
<TagAgents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@select-agent="content => insertSpecialContent('mention', content)"
/>
<CannedResponse
v-if="shouldShowCannedResponses"
:search-key="cannedSearchTerm"
@replace="content => insertSpecialContent('cannedResponse', content)"
/>
<VariableList
v-if="shouldShowVariables"
:search-key="variableSearchTerm"
@select-variable="content => insertSpecialContent('variable', content)"
/>
<KeyboardEmojiSelector
v-if="showEmojiMenu"
:search-key="emojiSearchTerm"
@select-emoji="emoji => insertSpecialContent('emoji', emoji)"
/>
<input
ref="imageUpload"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
hidden
@change="onFileChange"
/>
<div ref="editor" />
<div
v-show="isImageNodeSelected && showImageResizeToolbar"
class="absolute shadow-md rounded-[4px] flex gap-1 py-1 px-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50"
:style="{
top: toolbarPosition.top,
left: toolbarPosition.left,
}"
>
<button
v-for="size in sizes"
:key="size.name"
class="text-xs font-medium rounded-[4px] border border-solid border-slate-200 dark:border-slate-600 px-1.5 py-0.5 hover:bg-slate-100 dark:hover:bg-slate-800"
@click="setURLWithQueryAndImageSize(size)"
>
{{ size.name }}
</button>
</div>
<slot name="footer" />
</div>
</template>
<style lang="scss">
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.ProseMirror-menubar-wrapper {
@apply flex flex-col;
.ProseMirror-menubar {
min-height: var(--space-two) !important;
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
.ProseMirror-menu-active {
@apply bg-slate-75 dark:bg-slate-800;
}
}
> .ProseMirror {
@apply p-0 break-words text-slate-800 dark:text-slate-100;
h1,
h2,
h3,
h4,
h5,
h6,
p {
@apply text-slate-800 dark:text-slate-100;
}
blockquote {
@apply border-slate-400 dark:border-slate-500;
p {
@apply text-slate-600 dark:text-slate-400;
}
}
ol li {
@apply list-item list-decimal;
}
}
}
.ProseMirror-woot-style {
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@apply text-n-slate-12 mb-1.5;
}
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
}
}
.is-private {
.prosemirror-mention-node {
@apply font-medium bg-n-amber-2/80 dark:bg-n-amber-2/80 text-n-slate-12 py-0 px-1;
}
.ProseMirror-menubar-wrapper {
> .ProseMirror {
@apply text-n-slate-12;
p {
@apply text-n-slate-12;
}
}
}
}
.editor-wrap {
@apply mb-4;
}
.message-editor {
@apply rounded-lg outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 bg-n-alpha-black2 py-0 px-1 mb-0;
}
.editor_warning {
@apply outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9;
}
.editor-warning__message {
@apply text-red-400 dark:text-red-400 font-normal text-sm pt-1 pb-0 px-0;
}
</style>