# Pull Request Template ## Description This PR adds support for inline image uploads in the reply editor for Email and Website (chat widget) channels. Agents can now insert images inline between text and resize them directly in the editor by dragging the bottom corner, similar to the help center editor experience. Image sizes are preserved through markdown using the `cw_image_width` URL param and render correctly in both outgoing emails and chat widget messages. Agents can also paste copied images directly into Email or Website replies using **Shift+Cmd+V** (Shift+Ctrl+V on Windows/Linux). The image gets inserted inline at the cursor position and supports resizing just like uploaded images. Regular **Cmd+V / Ctrl+V** behavior remains unchanged and continues to add images as attachments, so both inline and attachment flows are supported. ### Prosemirror repo PR: https://github.com/chatwoot/prosemirror-schema/pull/48 Fixes https://linear.app/chatwoot/issue/CW-7133/inline-images-in-live-chat-and-email https://linear.app/chatwoot/issue/CW-7225/ghsa-8j9w-jppp-xcfc-html-attribute-injection-via-unvalidated-cw-image ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Screencast https://github.com/user-attachments/assets/a928f852-ab15-413a-9d35-6ea69b718ecf <img width="414" height="654" alt="image" src="https://github.com/user-attachments/assets/205e0729-8f2d-4cc5-9c55-7696f032eca4" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
1144 lines
31 KiB
Vue
1144 lines
31 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 TagTools from '../conversation/TagTools.vue';
|
|
import CopilotMenuBar from './CopilotMenuBar.vue';
|
|
|
|
import { useEmitter } from 'dashboard/composables/emitter';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useCaptain } from 'dashboard/composables/useCaptain';
|
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
|
import { useTrack } from 'dashboard/composables';
|
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
|
import { useAlert } from 'dashboard/composables';
|
|
import { vOnClickOutside } from '@vueuse/components';
|
|
|
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
|
import {
|
|
CONVERSATION_EVENTS,
|
|
CAPTAIN_EVENTS,
|
|
} from 'dashboard/helper/AnalyticsHelper/events';
|
|
|
|
import {
|
|
messageSchema,
|
|
buildMessageSchema,
|
|
buildEditor,
|
|
EditorView,
|
|
MessageMarkdownTransformer,
|
|
MessageMarkdownSerializer,
|
|
EditorState,
|
|
Selection,
|
|
imageResizeView,
|
|
} from '@chatwoot/prosemirror-schema';
|
|
import {
|
|
suggestionsPlugin,
|
|
triggerCharacters,
|
|
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
|
|
|
|
import {
|
|
appendSignature,
|
|
collapseSelection,
|
|
findNodeToInsertImage,
|
|
getContentNode,
|
|
insertAtCursor,
|
|
removeSignature as removeSignatureHelper,
|
|
scrollCursorIntoView,
|
|
getFormattingForEditor,
|
|
getSelectionCoords,
|
|
calculateMenuPosition,
|
|
getEffectiveChannelType,
|
|
stripUnsupportedFormatting,
|
|
} from 'dashboard/helper/editorHelper';
|
|
import {
|
|
hasPressedEnterAndNotCmdOrShift,
|
|
hasPressedCommandAndEnter,
|
|
isEscape,
|
|
} from 'shared/helpers/KeyboardHelpers';
|
|
import { createTypingIndicator } from '@chatwoot/utils';
|
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
|
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|
|
|
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 },
|
|
enableCaptainTools: { type: Boolean, default: false },
|
|
variables: { type: Object, 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: '' },
|
|
conversationId: { type: Number, default: null },
|
|
medium: { type: String, default: '' },
|
|
focusOnMount: { type: Boolean, default: true },
|
|
});
|
|
|
|
const emit = defineEmits([
|
|
'typingOn',
|
|
'typingOff',
|
|
'toggleUserMention',
|
|
'toggleCannedMenu',
|
|
'toggleVariablesMenu',
|
|
'toggleToolsMenu',
|
|
'clearSelection',
|
|
'blur',
|
|
'focus',
|
|
'input',
|
|
'update:modelValue',
|
|
'executeCopilotAction',
|
|
]);
|
|
|
|
const { t } = useI18n();
|
|
const { captainTasksEnabled } = useCaptain();
|
|
|
|
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
|
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
|
const DEFAULT_FORMATTING = 'Context::Default';
|
|
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
|
|
const MESSAGE_SIGNATURE_FORMATTING = 'Context::MessageSignature';
|
|
const INLINE_IMAGE_PASTE_TYPES = [
|
|
'image/png',
|
|
'image/jpeg',
|
|
'image/jpg',
|
|
'image/gif',
|
|
'image/webp',
|
|
];
|
|
|
|
const effectiveChannelType = computed(() =>
|
|
getEffectiveChannelType(props.channelType, props.medium)
|
|
);
|
|
|
|
const editorSchema = computed(() => {
|
|
if (!props.channelType) return messageSchema;
|
|
|
|
const formatType = props.isPrivate
|
|
? PRIVATE_NOTE_FORMATTING
|
|
: effectiveChannelType.value;
|
|
const formatting = getFormattingForEditor(
|
|
formatType,
|
|
captainTasksEnabled.value
|
|
);
|
|
return buildMessageSchema(formatting.marks, formatting.nodes);
|
|
});
|
|
|
|
const editorMenuOptions = computed(() => {
|
|
const formatType = props.isPrivate
|
|
? PRIVATE_NOTE_FORMATTING
|
|
: effectiveChannelType.value || DEFAULT_FORMATTING;
|
|
const formatting = getFormattingForEditor(
|
|
formatType,
|
|
captainTasksEnabled.value
|
|
);
|
|
|
|
return formatting.menu;
|
|
});
|
|
|
|
const createState = (content, placeholder, plugins = [], methods = {}) => {
|
|
const schema = editorSchema.value;
|
|
// Strip unsupported formatting before parsing to prevent "Token type not supported" errors
|
|
const sanitizedContent = stripUnsupportedFormatting(content, schema);
|
|
return EditorState.create({
|
|
doc: new MessageMarkdownTransformer(schema).parse(sanitizedContent),
|
|
plugins: buildEditor({
|
|
schema,
|
|
placeholder,
|
|
methods,
|
|
plugins,
|
|
enabledMenuOptions: editorMenuOptions.value,
|
|
}),
|
|
});
|
|
};
|
|
|
|
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 showToolsMenu = ref(false);
|
|
const mentionSearchKey = ref('');
|
|
const toolSearchKey = ref('');
|
|
const cannedSearchTerm = ref('');
|
|
const variableSearchTerm = ref('');
|
|
const emojiSearchTerm = ref('');
|
|
const range = ref(null);
|
|
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
|
const showSelectionMenu = ref(false);
|
|
|
|
// element ref
|
|
const editorRoot = useTemplateRef('editorRoot');
|
|
const imageUpload = useTemplateRef('imageUpload');
|
|
const editor = useTemplateRef('editor');
|
|
|
|
const isEditorMenuPopover = computed(
|
|
() =>
|
|
editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false
|
|
);
|
|
|
|
const handleCopilotAction = actionKey => {
|
|
if (actionKey === 'improve_selection' && editorView?.state) {
|
|
const { from, to } = editorView.state.selection;
|
|
const selectedText = editorView.state.doc.textBetween(from, to).trim();
|
|
|
|
if (from !== to && selectedText) {
|
|
emit('executeCopilotAction', 'improve', selectedText);
|
|
}
|
|
} else {
|
|
emit('executeCopilotAction', actionKey, props.modelValue);
|
|
}
|
|
|
|
showSelectionMenu.value = false;
|
|
};
|
|
|
|
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
|
|
);
|
|
});
|
|
|
|
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: showToolsMenu,
|
|
searchTerm: toolSearchKey,
|
|
isAllowed: () => props.enableCaptainTools,
|
|
}),
|
|
createSuggestionPlugin({
|
|
trigger: '@',
|
|
showMenu: showUserMentions,
|
|
searchTerm: mentionSearchKey,
|
|
isAllowed: () => props.isPrivate || !props.enableCaptainTools,
|
|
}),
|
|
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 &&
|
|
!props.disabled
|
|
) {
|
|
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);
|
|
});
|
|
watch(showToolsMenu, updatedValue => {
|
|
emit('toggleToolsMenu', props.enableCaptainTools && 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;
|
|
|
|
// Only strip the signature when it's actually being auto-appended for this
|
|
// draft. Otherwise an agent whose typed text happens to match their saved
|
|
// signature would be mistakenly treated as empty.
|
|
const bodyWithoutSignature =
|
|
sendWithSignature.value && props.signature
|
|
? removeSignatureHelper(
|
|
content,
|
|
props.signature,
|
|
effectiveChannelType.value
|
|
)
|
|
: content;
|
|
|
|
// trimming should remove all the whitespaces, so we can check the length
|
|
return bodyWithoutSignature.trim().length === 0;
|
|
}
|
|
|
|
function handleEmptyBodyWithSignature() {
|
|
const { schema, tr, doc } = state;
|
|
|
|
const isEmptyParagraph = node =>
|
|
node && node.type === schema.nodes.paragraph && node.content.size === 0;
|
|
|
|
// Check if empty paragraph already exists to prevent duplicates when toggling signatures
|
|
if (isEmptyParagraph(doc.firstChild)) {
|
|
focusEditorInputField('start');
|
|
return;
|
|
}
|
|
|
|
// 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 handleCopilotClick() {
|
|
const isOpening = !showSelectionMenu.value;
|
|
if (isOpening) {
|
|
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
|
|
conversationId: props.conversationId,
|
|
entryPoint: 'inline',
|
|
});
|
|
}
|
|
showSelectionMenu.value = isOpening;
|
|
}
|
|
|
|
function handleClickOutside(event) {
|
|
// Check if the clicked element or its parents have the ignored class
|
|
if (event.target.closest('.ProseMirror-copilot')) return;
|
|
showSelectionMenu.value = false;
|
|
}
|
|
|
|
function reloadState(content = props.modelValue) {
|
|
const unrefContent = unref(content);
|
|
state = createState(
|
|
unrefContent,
|
|
props.placeholder,
|
|
plugins.value,
|
|
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
|
|
editorMenuOptions.value
|
|
);
|
|
|
|
editorView.updateState(state);
|
|
focusEditor(unrefContent);
|
|
}
|
|
|
|
function addSignature() {
|
|
if (props.disabled) return;
|
|
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,
|
|
effectiveChannelType.value
|
|
);
|
|
// need to reload first, ensuring that the editorView is updated
|
|
reloadState(content);
|
|
|
|
if (contentWasEmpty) {
|
|
handleEmptyBodyWithSignature();
|
|
}
|
|
}
|
|
|
|
function removeSignature() {
|
|
if (props.disabled) return;
|
|
if (!props.signature) return;
|
|
let content = props.modelValue;
|
|
content = removeSignatureHelper(
|
|
content,
|
|
props.signature,
|
|
effectiveChannelType.value
|
|
);
|
|
// reload the state, ensuring that the editorView is updated
|
|
reloadState(content);
|
|
}
|
|
|
|
function setMenubarPosition({ selection } = {}) {
|
|
const wrapper = editorRoot.value;
|
|
if (!selection || !wrapper) return;
|
|
if (!isEditorMenuPopover.value) return;
|
|
|
|
const rect = wrapper.getBoundingClientRect();
|
|
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
|
|
|
|
// Calculate coords and final position
|
|
const coords = getSelectionCoords(editorView, selection, rect);
|
|
const { left, top, width } = calculateMenuPosition(coords, rect, isRtl);
|
|
|
|
wrapper.style.setProperty('--selection-left', `${left}px`);
|
|
wrapper.style.setProperty(
|
|
'--selection-right',
|
|
`${rect.width - left - width}px`
|
|
);
|
|
wrapper.style.setProperty('--selection-top', `${top}px`);
|
|
}
|
|
|
|
function checkSelection(editorState) {
|
|
showSelectionMenu.value = false;
|
|
const { selection } = editorState;
|
|
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
|
|
const hasSelection = !selection.empty && !selection.node;
|
|
if (hasSelection === isTextSelected.value) return;
|
|
|
|
isTextSelected.value = hasSelection;
|
|
const wrapper = editorRoot.value;
|
|
if (!wrapper) return;
|
|
|
|
wrapper.classList.toggle('has-selection', hasSelection);
|
|
if (hasSelection) setMenubarPosition(editorState);
|
|
}
|
|
|
|
function emitOnChange() {
|
|
emit('input', contentFromEditor());
|
|
emit('update:modelValue', contentFromEditor());
|
|
}
|
|
|
|
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();
|
|
}
|
|
// reloadState replaces editor state directly and bypasses dispatchTransaction,
|
|
// so v-model never hears about the signature change — sync it back explicitly.
|
|
emitOnChange();
|
|
}
|
|
|
|
function isEnterToSendEnabled() {
|
|
return isEditorHotKeyEnabled('enter');
|
|
}
|
|
|
|
function isCmdPlusEnterToSendEnabled() {
|
|
return isEditorHotKeyEnabled('cmd_enter');
|
|
}
|
|
|
|
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(
|
|
props.channelType === MESSAGE_SIGNATURE_FORMATTING
|
|
? t(
|
|
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
|
|
)
|
|
: t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS')
|
|
);
|
|
} catch (error) {
|
|
useAlert(
|
|
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_ERROR')
|
|
);
|
|
}
|
|
}
|
|
|
|
function uploadImageIfWithinSizeLimit(file) {
|
|
if (!file) return;
|
|
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,
|
|
}
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
function onFileChange() {
|
|
const input = imageUpload.value;
|
|
uploadImageIfWithinSizeLimit(input.files[0]);
|
|
input.value = '';
|
|
}
|
|
|
|
const allowsInlineImagePaste = computed(
|
|
() =>
|
|
!props.isPrivate &&
|
|
(props.channelType === INBOX_TYPES.EMAIL ||
|
|
props.channelType === INBOX_TYPES.WEB)
|
|
);
|
|
|
|
// Shift+Cmd/Ctrl+V on email/website: upload a clipboard image inline. This
|
|
// gesture's native paste event carries no image, so clipboard.read() is the
|
|
// only way to get the bytes. No preventDefault: text still pastes natively.
|
|
async function pasteInlineImageFromClipboard() {
|
|
if (!editorView?.hasFocus()) return;
|
|
if (!allowsInlineImagePaste.value || !navigator.clipboard?.read) return;
|
|
try {
|
|
const items = await navigator.clipboard.read();
|
|
const imageItem = items.find(item =>
|
|
item.types.some(type => INLINE_IMAGE_PASTE_TYPES.includes(type))
|
|
);
|
|
if (!imageItem) return;
|
|
const imageType = imageItem.types.find(type =>
|
|
INLINE_IMAGE_PASTE_TYPES.includes(type)
|
|
);
|
|
const blob = await imageItem.getType(imageType);
|
|
uploadImageIfWithinSizeLimit(
|
|
new File([blob], 'pasted-image', { type: imageType })
|
|
);
|
|
} catch (error) {
|
|
// clipboard-read denied/unfocused (NotAllowedError): image can't be read.
|
|
// Text paste is unaffected — ProseMirror handles it from the native event.
|
|
}
|
|
}
|
|
|
|
useKeyboardEvents({
|
|
'Alt+KeyP': {
|
|
action: focusEditorInputField,
|
|
allowOnFocusedInput: false,
|
|
},
|
|
'Alt+KeyL': {
|
|
action: focusEditorInputField,
|
|
allowOnFocusedInput: false,
|
|
},
|
|
'$mod+Shift+KeyV': {
|
|
action: pasteInlineImageFromClipboard,
|
|
allowOnFocusedInput: true,
|
|
},
|
|
});
|
|
|
|
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;
|
|
// Use the editor's current schema to ensure compatibility with buildMessageSchema
|
|
const currentSchema = editorView.state.schema;
|
|
// Strip unsupported formatting before parsing to ensure content can be inserted
|
|
// into channels that don't support certain markdown features (e.g., API channels)
|
|
const sanitizedContent = stripUnsupportedFormatting(content, currentSchema);
|
|
let node = new MessageMarkdownTransformer(currentSchema).parse(
|
|
sanitizedContent
|
|
);
|
|
|
|
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,
|
|
tool: CONVERSATION_EVENTS.INSERTED_A_TOOL,
|
|
};
|
|
|
|
useTrack(event_map[type]);
|
|
}
|
|
|
|
function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
|
|
if (
|
|
hasPressedCommandAndEnter(event) &&
|
|
isCmdPlusEnterToSendEnabled() &&
|
|
!props.overrideLineBreaks
|
|
) {
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
function onKeydown(event) {
|
|
if (isEscape(event)) {
|
|
collapseSelection(editorView);
|
|
return true;
|
|
}
|
|
if (isEnterToSendEnabled()) {
|
|
handleLineBreakWhenEnterToSendEnabled(event);
|
|
}
|
|
if (isCmdPlusEnterToSendEnabled()) {
|
|
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function createEditorView() {
|
|
editorView = new EditorView(editor.value, {
|
|
state: state,
|
|
editable: () => !props.disabled,
|
|
nodeViews: {
|
|
image: imageResizeView,
|
|
},
|
|
dispatchTransaction: tx => {
|
|
state = state.apply(tx);
|
|
editorView.updateState(state);
|
|
if (tx.docChanged) {
|
|
emitOnChange();
|
|
}
|
|
checkSelection(state);
|
|
},
|
|
handleDOMEvents: {
|
|
keyup: () => {
|
|
if (!props.disabled) {
|
|
typingIndicator.start();
|
|
}
|
|
},
|
|
keydown: (view, event) => !props.disabled && onKeydown(event),
|
|
focus: () => !props.disabled && emit('focus'),
|
|
blur: () => {
|
|
if (props.disabled) return;
|
|
typingIndicator.stop();
|
|
// PM keeps its selection on blur — clear the menu flags manually.
|
|
isTextSelected.value = false;
|
|
editorRoot.value?.classList.remove('has-selection');
|
|
emit('blur');
|
|
},
|
|
paste: (view, event) => {
|
|
if (props.disabled) return;
|
|
const { files } = event.clipboardData;
|
|
if (!files.length) return;
|
|
event.preventDefault();
|
|
// Paste text content alongside files (e.g., spreadsheet data from Numbers app)
|
|
// Numbers app includes invalid 0-byte attachments with text, so we paste the text here
|
|
// while ReplyBox filters and handles valid file attachments
|
|
const text = event.clipboardData.getData('text/plain');
|
|
if (text) {
|
|
view.dispatch(view.state.tr.insertText(text));
|
|
emitOnChange();
|
|
}
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
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.disabled),
|
|
() => editorView?.setProps({})
|
|
);
|
|
|
|
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 && !props.disabled) {
|
|
toggleSignatureInEditor(newValue);
|
|
}
|
|
});
|
|
|
|
onMounted(() => {
|
|
// [VITE] state assignment was done in created before
|
|
state = createState(
|
|
props.modelValue,
|
|
props.placeholder,
|
|
plugins.value,
|
|
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
|
|
editorMenuOptions.value
|
|
);
|
|
|
|
createEditorView();
|
|
editorView.updateState(state);
|
|
if (props.focusOnMount) {
|
|
focusEditorInputField();
|
|
}
|
|
});
|
|
|
|
defineExpose({ 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"
|
|
:class="{
|
|
'opacity-50 cursor-not-allowed pointer-events-none': disabled,
|
|
}"
|
|
>
|
|
<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)"
|
|
/>
|
|
<TagTools
|
|
v-if="showToolsMenu"
|
|
:search-key="toolSearchKey"
|
|
@select-tool="content => insertSpecialContent('tool', content)"
|
|
/>
|
|
<CopilotMenuBar
|
|
v-if="showSelectionMenu"
|
|
v-on-click-outside="handleClickOutside"
|
|
:has-selection="isTextSelected"
|
|
:is-editor-menu-popover="isEditorMenuPopover"
|
|
:has-content="!isBodyEmpty(modelValue)"
|
|
:conversation-id="conversationId"
|
|
:show-selection-menu="showSelectionMenu"
|
|
:show-general-menu="false"
|
|
class="copilot-editor-menu"
|
|
@execute-copilot-action="handleCopilotAction"
|
|
/>
|
|
<input
|
|
ref="imageUpload"
|
|
type="file"
|
|
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
|
hidden
|
|
@change="onFileChange"
|
|
/>
|
|
<div ref="editor" />
|
|
<slot name="footer" />
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
|
|
|
.ProseMirror-menubar-wrapper {
|
|
@apply flex flex-col gap-3;
|
|
|
|
.ProseMirror-menubar {
|
|
min-height: 1.25rem !important;
|
|
@apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
|
|
|
|
.ProseMirror-menu-active {
|
|
@apply bg-n-slate-5 dark:bg-n-solid-3 !important;
|
|
}
|
|
|
|
.ProseMirror-menuitem {
|
|
@apply mr-0 size-4 flex items-center justify-center;
|
|
|
|
.ProseMirror-icon {
|
|
@apply size-4 flex items-center justify-center flex-shrink-0;
|
|
|
|
svg {
|
|
@apply size-full;
|
|
}
|
|
}
|
|
|
|
.ProseMirror-copilot svg {
|
|
@apply fill-n-violet-9 text-n-violet-9 stroke-none;
|
|
}
|
|
}
|
|
}
|
|
|
|
.ProseMirror-menubar:not(:has(*)) {
|
|
max-height: none !important;
|
|
min-height: 0 !important;
|
|
padding: 0 !important;
|
|
display: none !important;
|
|
}
|
|
|
|
> .ProseMirror {
|
|
@apply p-0 break-words text-n-slate-12;
|
|
|
|
h1,
|
|
h2,
|
|
h3,
|
|
h4,
|
|
h5,
|
|
h6,
|
|
p {
|
|
@apply text-n-slate-12;
|
|
}
|
|
|
|
blockquote {
|
|
@apply border-n-slate-7;
|
|
|
|
p {
|
|
@apply text-n-slate-11;
|
|
}
|
|
}
|
|
|
|
ol li {
|
|
@apply list-item list-decimal;
|
|
}
|
|
}
|
|
}
|
|
|
|
.ProseMirror-woot-style {
|
|
@apply overflow-auto;
|
|
}
|
|
|
|
.ProseMirror-woot-style:not(
|
|
:where(.resizable-editor-wrapper .ProseMirror-woot-style)
|
|
) {
|
|
@apply min-h-[5rem] max-h-[7.5rem];
|
|
}
|
|
|
|
// Resizable editor wrapper styles
|
|
.resizable-editor-wrapper {
|
|
.ProseMirror-woot-style {
|
|
min-height: clamp(
|
|
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
|
|
var(--editor-height, var(--editor-min-height, 5rem)),
|
|
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
|
|
);
|
|
max-height: clamp(
|
|
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
|
|
var(--editor-height, var(--editor-min-height, 5rem)),
|
|
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
|
|
);
|
|
transition:
|
|
min-height var(--editor-height-transition, 180ms ease),
|
|
max-height var(--editor-height-transition, 180ms ease);
|
|
}
|
|
}
|
|
|
|
.ProseMirror-prompt-backdrop::backdrop {
|
|
@apply bg-n-alpha-black1 backdrop-blur-[4px];
|
|
}
|
|
|
|
.ProseMirror-prompt {
|
|
@apply bg-n-alpha-3 border border-n-strong p-6 shadow-xl rounded-xl w-96 !important;
|
|
|
|
h5 {
|
|
@apply text-n-slate-12 mb-3;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.prosemirror-tools-node {
|
|
@apply font-medium text-n-slate-12 py-0;
|
|
}
|
|
|
|
.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-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
|
|
}
|
|
|
|
// Default copilot menu position (non-popover editors like components-next/Editor)
|
|
// When popover-prosemirror-menu is NOT on the wrapper, anchor below the menubar
|
|
:not(.popover-prosemirror-menu) > .copilot-editor-menu {
|
|
top: 1.5rem !important;
|
|
|
|
[dir='rtl'] & {
|
|
left: auto !important;
|
|
right: 0 !important;
|
|
}
|
|
}
|
|
|
|
// Float editor menu
|
|
.popover-prosemirror-menu {
|
|
position: relative;
|
|
|
|
.ProseMirror p:last-child {
|
|
margin-bottom: 10px !important;
|
|
}
|
|
|
|
.ProseMirror-menubar {
|
|
display: none; // Hide by default
|
|
}
|
|
|
|
&.has-selection {
|
|
// Hide menu completely when it has no items
|
|
.ProseMirror-menubar:not(:has(*)) {
|
|
display: none !important;
|
|
}
|
|
|
|
.ProseMirror-menubar {
|
|
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
|
display: flex;
|
|
width: fit-content !important;
|
|
position: absolute !important;
|
|
|
|
// Default/LTR: position from left
|
|
top: var(--selection-top);
|
|
left: var(--selection-left);
|
|
|
|
// RTL: position from right instead
|
|
[dir='rtl'] & {
|
|
left: auto;
|
|
right: var(--selection-right);
|
|
}
|
|
|
|
.ProseMirror-menuitem {
|
|
@apply mr-0 size-4 flex items-center;
|
|
|
|
.ProseMirror-icon {
|
|
@apply p-0.5 flex-shrink-0;
|
|
}
|
|
|
|
.ProseMirror-copilot svg {
|
|
@apply fill-n-violet-9 text-n-violet-9 stroke-none;
|
|
}
|
|
}
|
|
|
|
.ProseMirror-menu-active {
|
|
@apply bg-n-slate-3;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|