diff --git a/Gemfile b/Gemfile
index 46b11ef1d..1ae6cf093 100644
--- a/Gemfile
+++ b/Gemfile
@@ -215,7 +215,7 @@ group :production do
end
group :development do
- gem 'annotate'
+ gem 'annotaterb'
gem 'bullet'
gem 'letter_opener'
gem 'scss_lint', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 55cfdef7e..b42472ef4 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -128,9 +128,9 @@ GEM
selectize-rails (~> 0.6)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
- annotate (3.2.0)
- activerecord (>= 3.2, < 8.0)
- rake (>= 10.4, < 14.0)
+ annotaterb (4.20.0)
+ activerecord (>= 6.0.0)
+ activesupport (>= 6.0.0)
ast (2.4.3)
attr_extras (7.1.0)
audited (5.4.1)
@@ -1018,7 +1018,7 @@ DEPENDENCIES
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.7.0)
- annotate
+ annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue
index 67936fa59..ff1c616de 100644
--- a/app/javascript/dashboard/components-next/Editor/Editor.vue
+++ b/app/javascript/dashboard/components-next/Editor/Editor.vue
@@ -19,7 +19,6 @@ const props = defineProps({
},
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
- enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false },
@@ -102,7 +101,6 @@ watch(
:disabled="disabled"
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
- :enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
@@ -139,19 +137,6 @@ watch(
.editor-wrapper {
::v-deep {
.ProseMirror-menubar-wrapper {
- @apply gap-2 !important;
-
- .ProseMirror-menubar {
- @apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
-
- .ProseMirror-menuitem {
- @apply h-5 !important;
- }
-
- .ProseMirror-icon {
- @apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
- }
- }
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue
index 7beff200e..4c4d95f0c 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue
@@ -172,7 +172,7 @@ const previewArticle = () => {
@apply mr-0;
.ProseMirror-icon {
- @apply p-0 mt-1 !mr-0;
+ @apply p-0 mt-0 !mr-0;
svg {
width: 20px !important;
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index 8d7a082b4..323b911ec 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -26,13 +26,11 @@ 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 { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
messageSchema,
+ buildMessageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
@@ -53,6 +51,9 @@ import {
removeSignature as removeSignatureHelper,
scrollCursorIntoView,
setURLWithQueryAndSize,
+ getFormattingForEditor,
+ getSelectionCoords,
+ calculateMenuPosition,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -75,7 +76,6 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true },
enableCaptainTools: { type: Boolean, default: false },
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
@@ -103,22 +103,34 @@ const { t } = useI18n();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
+const DEFAULT_FORMATTING = 'Context::Default';
-const createState = (
- content,
- placeholder,
- plugins = [],
- methods = {},
- enabledMenuOptions = []
-) => {
+const editorSchema = computed(() => {
+ if (!props.channelType) return messageSchema;
+
+ const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType;
+ const formatting = getFormattingForEditor(formatType);
+ return buildMessageSchema(formatting.marks, formatting.nodes);
+});
+
+const editorMenuOptions = computed(() => {
+ const formatType = props.isPrivate
+ ? DEFAULT_FORMATTING
+ : props.channelType || DEFAULT_FORMATTING;
+ const formatting = getFormattingForEditor(formatType);
+ return formatting.menu;
+});
+
+const createState = (content, placeholder, plugins = [], methods = {}) => {
+ const schema = editorSchema.value;
return EditorState.create({
- doc: new MessageMarkdownTransformer(messageSchema).parse(content),
+ doc: new MessageMarkdownTransformer(schema).parse(content),
plugins: buildEditor({
- schema: messageSchema,
+ schema,
placeholder,
methods,
plugins,
- enabledMenuOptions,
+ enabledMenuOptions: editorMenuOptions.value,
}),
});
};
@@ -153,6 +165,8 @@ const range = ref(null);
const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null);
+const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
+const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref
@@ -174,12 +188,6 @@ const shouldShowCannedResponses = computed(() => {
);
});
-const editorMenuOptions = computed(() => {
- return props.enabledMenuOptions.length
- ? props.enabledMenuOptions
- : MESSAGE_EDITOR_MENU_OPTIONS;
-});
-
function createSuggestionPlugin({
trigger,
minChars = 0,
@@ -400,6 +408,38 @@ function setToolbarPosition() {
};
}
+function setMenubarPosition({ selection } = {}) {
+ const wrapper = editorRoot.value;
+ if (!selection || !wrapper) 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 hasSelection = editorState.selection.from !== editorState.selection.to;
+ 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 setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) {
return;
@@ -529,7 +569,9 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) {
function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
- let node = new MessageMarkdownTransformer(messageSchema).parse(content);
+ // Use the editor's current schema to ensure compatibility with buildMessageSchema
+ const currentSchema = editorView.state.schema;
+ let node = new MessageMarkdownTransformer(currentSchema).parse(content);
insertNodeIntoEditor(node, from, undefined);
}
@@ -596,6 +638,7 @@ function createEditorView() {
if (tx.docChanged) {
emitOnChange();
}
+ checkSelection(state);
},
handleDOMEvents: {
keyup: () => {
@@ -761,15 +804,33 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.ProseMirror-menubar-wrapper {
- @apply flex flex-col;
+ @apply flex flex-col gap-3;
.ProseMirror-menubar {
min-height: 1.25rem !important;
- @apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
+ @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;
+ @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-menubar:not(:has(*)) {
+ max-height: none !important;
+ min-height: 0 !important;
+ padding: 0 !important;
}
> .ProseMirror {
@@ -860,4 +921,53 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.editor-warning__message {
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
}
+
+// 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-menu-active {
+ @apply bg-n-slate-3;
+ }
+ }
+ }
+}
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
index bc43f4869..80737ac45 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
@@ -78,10 +78,6 @@ export default {
type: Boolean,
default: false,
},
- showEditorToggle: {
- type: Boolean,
- default: false,
- },
isOnPrivateNote: {
type: Boolean,
default: false,
@@ -130,7 +126,6 @@ export default {
emits: [
'replaceText',
'toggleInsertArticle',
- 'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
@@ -325,18 +320,8 @@ export default {
sm
@click="toggleAudioRecorder"
/>
-
line.trim() !== '');
- const filteredMarkdown = nonEmptyLines.join(' ');
- emitter.emit(
- BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
- `[${filteredMarkdown}](${url})`
- );
- } else {
- this.addIntoEditor(
- `${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
- );
- }
+ // Removing empty lines from the title
+ const lines = title.split('\n');
+ const nonEmptyLines = lines.filter(line => line.trim() !== '');
+ const filteredMarkdown = nonEmptyLines.join(' ');
+ emitter.emit(
+ BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
+ `[${filteredMarkdown}](${url})`
+ );
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
- toggleRichContentEditor() {
- this.updateUISettings({
- display_rich_content_editor: !this.showRichContentEditor,
- });
-
- const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
-
- if (!this.showRichContentEditor && this.messageSignature) {
- // remove the old signature -> extract text from markdown -> attach new signature
- let message = removeSignature(this.message, this.messageSignature);
- message = extractTextFromMarkdown(message);
- message = appendSignature(message, plainTextSignature);
-
- this.message = message;
- } else {
- this.message = replaceSignature(
- this.message,
- plainTextSignature,
- this.messageSignature
- );
- }
- },
toggleQuotedReply() {
if (!this.isAnEmailChannel) {
return;
@@ -655,8 +565,8 @@ export default {
}
return this.sendWithSignature
- ? appendSignature(message, this.signatureToApply)
- : removeSignature(message, this.signatureToApply);
+ ? appendSignature(message, this.messageSignature)
+ : removeSignature(message, this.messageSignature);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
@@ -672,7 +582,6 @@ export default {
Escape: {
action: () => {
this.hideEmojiPicker();
- this.hideMentions();
},
allowOnFocusedInput: true,
},
@@ -715,9 +624,6 @@ export default {
},
onPaste(e) {
const data = e.clipboardData.files;
- if (!this.showRichContentEditor && data.length !== 0) {
- this.$refs.messageInput.$el.blur();
- }
if (!data.length || !data[0]) {
return;
}
@@ -851,7 +757,7 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
- message = appendSignature(message, this.signatureToApply);
+ message = appendSignature(message, this.messageSignature);
}
const updatedMessage = replaceVariablesInMessage({
@@ -875,40 +781,22 @@ export default {
});
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
- if (this.showRichContentEditor) {
- if (this.isRecordingAudio) {
- this.toggleAudioRecorder();
- }
- return;
+ if (this.isRecordingAudio) {
+ this.toggleAudioRecorder();
}
- this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
- insertIntoTextEditor(text, selectionStart, selectionEnd) {
- const { message } = this;
- const newMessage =
- message.slice(0, selectionStart) +
- text +
- message.slice(selectionEnd, message.length);
- this.message = newMessage;
- },
addIntoEditor(content) {
- if (this.showRichContentEditor) {
- this.updateEditorSelectionWith = content;
- this.onFocus();
- }
- if (!this.showRichContentEditor) {
- const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
- this.insertIntoTextEditor(content, selectionStart, selectionEnd);
- }
+ this.updateEditorSelectionWith = content;
+ this.onFocus();
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
- this.message = appendSignature(this.message, this.signatureToApply);
+ this.message = appendSignature(this.message, this.messageSignature);
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -926,19 +814,15 @@ export default {
},
toggleAudioRecorder() {
this.isRecordingAudio = !this.isRecordingAudio;
- this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) {
this.resetAudioRecorderInput();
}
},
toggleAudioRecorderPlayPause() {
- if (!this.isRecordingAudio) {
- return;
- }
- if (!this.isRecorderAudioStopped) {
- this.isRecorderAudioStopped = true;
+ if (!this.$refs.audioRecorderInput) return;
+ if (!this.recordingAudioState) {
this.$refs.audioRecorderInput.stopRecording();
- } else if (this.isRecorderAudioStopped) {
+ } else {
this.$refs.audioRecorderInput.playPause();
}
},
@@ -947,9 +831,6 @@ export default {
this.toggleEmojiPicker();
}
},
- hideMentions() {
- this.showMentions = false;
- },
onTypingOn() {
this.toggleTyping('on');
},
@@ -1196,13 +1077,6 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
-
-
diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js
index 9a99516e8..9633687ec 100644
--- a/app/javascript/dashboard/constants/editor.js
+++ b/app/javascript/dashboard/constants/editor.js
@@ -1,23 +1,143 @@
-export const MESSAGE_EDITOR_MENU_OPTIONS = [
- 'strong',
- 'em',
- 'link',
- 'undo',
- 'redo',
- 'bulletList',
- 'orderedList',
- 'code',
-];
-
-export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
- 'strong',
- 'em',
- 'link',
- 'undo',
- 'redo',
- 'imageUpload',
-];
+// Formatting rules for different contexts (channels and special contexts)
+// marks: inline formatting (strong, em, code, link, strike)
+// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
+export const FORMATTING = {
+ // Channel formatting
+ 'Channel::Email': {
+ marks: ['strong', 'em', 'code', 'link'],
+ nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
+ menu: [
+ 'strong',
+ 'em',
+ 'code',
+ 'link',
+ 'bulletList',
+ 'orderedList',
+ 'undo',
+ 'redo',
+ ],
+ },
+ 'Channel::WebWidget': {
+ marks: ['strong', 'em', 'code', 'link', 'strike'],
+ nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
+ menu: [
+ 'strong',
+ 'em',
+ 'code',
+ 'link',
+ 'strike',
+ 'bulletList',
+ 'orderedList',
+ 'undo',
+ 'redo',
+ ],
+ },
+ 'Channel::Api': {
+ marks: [],
+ nodes: [],
+ menu: [],
+ },
+ 'Channel::FacebookPage': {
+ marks: ['strong', 'em', 'code', 'strike'],
+ nodes: ['bulletList', 'orderedList', 'codeBlock'],
+ menu: [
+ 'strong',
+ 'em',
+ 'code',
+ 'strike',
+ 'bulletList',
+ 'orderedList',
+ 'undo',
+ 'redo',
+ ],
+ },
+ 'Channel::TwitterProfile': {
+ marks: [],
+ nodes: [],
+ menu: [],
+ },
+ 'Channel::TwilioSms': {
+ marks: [],
+ nodes: [],
+ menu: [],
+ },
+ 'Channel::Sms': {
+ marks: [],
+ nodes: [],
+ menu: [],
+ },
+ 'Channel::Whatsapp': {
+ marks: ['strong', 'em', 'code', 'strike'],
+ nodes: ['bulletList', 'orderedList', 'codeBlock'],
+ menu: [
+ 'strong',
+ 'em',
+ 'code',
+ 'strike',
+ 'bulletList',
+ 'orderedList',
+ 'undo',
+ 'redo',
+ ],
+ },
+ 'Channel::Line': {
+ marks: ['strong', 'em', 'code', 'strike'],
+ nodes: ['codeBlock'],
+ menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
+ },
+ 'Channel::Telegram': {
+ marks: ['strong', 'em', 'link', 'code'],
+ nodes: [],
+ menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
+ },
+ 'Channel::Instagram': {
+ marks: ['strong', 'em', 'code', 'strike'],
+ nodes: ['bulletList', 'orderedList'],
+ menu: [
+ 'strong',
+ 'em',
+ 'code',
+ 'bulletList',
+ 'orderedList',
+ 'strike',
+ 'undo',
+ 'redo',
+ ],
+ },
+ 'Channel::Voice': {
+ marks: [],
+ nodes: [],
+ menu: [],
+ },
+ // Special contexts (not actual channels)
+ 'Context::Default': {
+ marks: ['strong', 'em', 'code', 'link', 'strike'],
+ nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
+ menu: [
+ 'strong',
+ 'em',
+ 'code',
+ 'link',
+ 'strike',
+ 'bulletList',
+ 'orderedList',
+ 'undo',
+ 'redo',
+ ],
+ },
+ 'Context::MessageSignature': {
+ marks: ['strong', 'em', 'link'],
+ nodes: [],
+ menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
+ },
+ 'Context::InboxSettings': {
+ marks: ['strong', 'em', 'link'],
+ nodes: [],
+ menu: ['strong', 'em', 'link', 'undo', 'redo'],
+ },
+};
+// Editor menu options for Full Editor
export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
@@ -33,14 +153,7 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code',
];
-export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
- 'strong',
- 'em',
- 'link',
- 'undo',
- 'redo',
-];
-
+// Editor image resize options for Message Editor
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js
index ae4c4a8cc..3722bbeed 100644
--- a/app/javascript/dashboard/helper/editorHelper.js
+++ b/app/javascript/dashboard/helper/editorHelper.js
@@ -5,6 +5,7 @@ import {
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
+import { FORMATTING } from 'dashboard/constants/editor';
/**
* The delimiter used to separate the signature from the rest of the body.
@@ -314,7 +315,7 @@ const createNode = (editorView, nodeType, content) => {
return mentionNode;
}
case 'cannedResponse':
- return new MessageMarkdownTransformer(messageSchema).parse(content);
+ return new MessageMarkdownTransformer(state.schema).parse(content);
case 'variable':
return state.schema.text(`{{${content}}}`);
case 'emoji':
@@ -389,3 +390,85 @@ export const getContentNode = (
? creator(editorView, content, from, to, variables)
: { node: null, from, to };
};
+
+/**
+ * Get the formatting configuration for a specific channel type.
+ * Returns the appropriate marks, nodes, and menu items for the editor.
+ *
+ * @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
+ * @returns {Object} The formatting configuration with marks, nodes, and menu properties
+ */
+export function getFormattingForEditor(channelType) {
+ return FORMATTING[channelType] || FORMATTING['Context::Default'];
+}
+
+/**
+ * Menu Positioning Helpers
+ * Handles floating menu bar positioning for text selection in the editor.
+ */
+
+const MENU_CONFIG = { H: 46, W: 300, GAP: 10 };
+
+/**
+ * Calculate selection coordinates with bias to handle line-wraps correctly.
+ * @param {EditorView} editorView - ProseMirror editor view
+ * @param {Selection} selection - Current text selection
+ * @param {DOMRect} rect - Container bounding rect
+ * @returns {{start: Object, end: Object, selTop: number, onTop: boolean}}
+ */
+export function getSelectionCoords(editorView, selection, rect) {
+ const start = editorView.coordsAtPos(selection.from, 1);
+ const end = editorView.coordsAtPos(selection.to, -1);
+
+ const selTop = Math.min(start.top, end.top);
+ const spaceAbove = selTop - rect.top;
+ const onTop =
+ spaceAbove > MENU_CONFIG.H + MENU_CONFIG.GAP || end.bottom > rect.bottom;
+
+ return { start, end, selTop, onTop };
+}
+
+/**
+ * Calculate anchor position based on selection visibility and RTL direction.
+ * @param {Object} coords - Selection coordinates from getSelectionCoords
+ * @param {DOMRect} rect - Container bounding rect
+ * @param {boolean} isRtl - Whether text direction is RTL
+ * @returns {number} Anchor x-position for menu
+ */
+export function getMenuAnchor(coords, rect, isRtl) {
+ const { start, end, onTop } = coords;
+
+ if (!onTop) return end.left;
+
+ // If start of selection is visible, align to text. Else stick to container edge.
+ if (start.top >= rect.top) return isRtl ? start.right : start.left;
+
+ return isRtl ? rect.right - MENU_CONFIG.GAP : rect.left + MENU_CONFIG.GAP;
+}
+
+/**
+ * Calculate final menu position (left, top) within container bounds.
+ * @param {Object} coords - Selection coordinates from getSelectionCoords
+ * @param {DOMRect} rect - Container bounding rect
+ * @param {boolean} isRtl - Whether text direction is RTL
+ * @returns {{left: number, top: number, width: number}}
+ */
+export function calculateMenuPosition(coords, rect, isRtl) {
+ const { start, end, selTop, onTop } = coords;
+
+ const anchor = getMenuAnchor(coords, rect, isRtl);
+
+ // Calculate Left: shift by width if RTL, then make relative to container
+ const rawLeft = (isRtl ? anchor - MENU_CONFIG.W : anchor) - rect.left;
+
+ // Ensure menu stays within container bounds
+ const left = Math.min(Math.max(0, rawLeft), rect.width - MENU_CONFIG.W);
+
+ // Calculate Top: align to selection or bottom of selection
+ const top = onTop
+ ? Math.max(-26, selTop - rect.top - MENU_CONFIG.H - MENU_CONFIG.GAP)
+ : Math.max(start.bottom, end.bottom) - rect.top + MENU_CONFIG.GAP;
+ return { left, top, width: MENU_CONFIG.W };
+}
+
+/* End Menu Positioning Helpers */
diff --git a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
index 56b162d50..4efb4d1d9 100644
--- a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
@@ -1,15 +1,11 @@
// 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 { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
vi.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: vi.fn(),
- messageSchema: {},
}));
vi.mock('@chatwoot/utils', () => ({
@@ -62,12 +58,18 @@ describe('getContentNode', () => {
const to = 10;
const updatedMessage = 'Hello John';
- replaceVariablesInMessage.mockReturnValue(updatedMessage);
- MessageMarkdownTransformer.mockImplementation(() => ({
- parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
- }));
+ // Mock the node that will be returned by parse
+ const mockNode = { textContent: updatedMessage };
- const { node } = getContentNode(
+ replaceVariablesInMessage.mockReturnValue(updatedMessage);
+
+ // Mock MessageMarkdownTransformer instance with parse method
+ const mockTransformer = {
+ parse: vi.fn().mockReturnValue(mockNode),
+ };
+ MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
+
+ const result = getContentNode(
editorView,
'cannedResponse',
content,
@@ -79,8 +81,15 @@ describe('getContentNode', () => {
message: content,
variables,
});
- expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
- expect(node.textContent).toBe(updatedMessage);
+ expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
+ editorView.state.schema
+ );
+ expect(mockTransformer.parse).toHaveBeenCalledWith(updatedMessage);
+ expect(result.node).toBe(mockNode);
+ expect(result.node.textContent).toBe(updatedMessage);
+ // When textContent matches updatedMessage, from should remain unchanged
+ expect(result.from).toBe(from);
+ expect(result.to).toBe(to);
});
});
diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
index 4ed9170c2..664a1a42f 100644
--- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
@@ -9,7 +9,12 @@ import {
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
+ getFormattingForEditor,
+ getSelectionCoords,
+ getMenuAnchor,
+ calculateMenuPosition,
} from '../editorHelper';
+import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema';
import { Schema } from 'prosemirror-model';
@@ -258,15 +263,11 @@ describe('insertAtCursor', () => {
expect(result).toBeUndefined();
});
- it('should unwrap doc nodes that are wrapped in a paragraph', () => {
- const docNode = schema.node('doc', null, [
- schema.node('paragraph', null, [schema.text('Hello')]),
- ]);
-
+ it('should insert text node at cursor position', () => {
const editorState = createEditorState();
const editorView = new EditorView(document.body, { state: editorState });
- insertAtCursor(editorView, docNode, 0);
+ insertAtCursor(editorView, schema.text('Hello'), 0);
// Check if node was unwrapped and inserted correctly
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
@@ -626,3 +627,178 @@ describe('getContentNode', () => {
});
});
});
+
+describe('getFormattingForEditor', () => {
+ describe('channel-specific formatting', () => {
+ it('returns full formatting for Email channel', () => {
+ const result = getFormattingForEditor('Channel::Email');
+
+ expect(result).toEqual(FORMATTING['Channel::Email']);
+ });
+
+ it('returns full formatting for WebWidget channel', () => {
+ const result = getFormattingForEditor('Channel::WebWidget');
+
+ expect(result).toEqual(FORMATTING['Channel::WebWidget']);
+ });
+
+ it('returns limited formatting for WhatsApp channel', () => {
+ const result = getFormattingForEditor('Channel::Whatsapp');
+
+ expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
+ });
+
+ it('returns no formatting for API channel', () => {
+ const result = getFormattingForEditor('Channel::Api');
+
+ expect(result).toEqual(FORMATTING['Channel::Api']);
+ });
+
+ it('returns limited formatting for FacebookPage channel', () => {
+ const result = getFormattingForEditor('Channel::FacebookPage');
+
+ expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
+ });
+
+ it('returns no formatting for TwitterProfile channel', () => {
+ const result = getFormattingForEditor('Channel::TwitterProfile');
+
+ expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
+ });
+
+ it('returns no formatting for SMS channel', () => {
+ const result = getFormattingForEditor('Channel::Sms');
+
+ expect(result).toEqual(FORMATTING['Channel::Sms']);
+ });
+
+ it('returns limited formatting for Telegram channel', () => {
+ const result = getFormattingForEditor('Channel::Telegram');
+
+ expect(result).toEqual(FORMATTING['Channel::Telegram']);
+ });
+
+ it('returns formatting for Instagram channel', () => {
+ const result = getFormattingForEditor('Channel::Instagram');
+
+ expect(result).toEqual(FORMATTING['Channel::Instagram']);
+ });
+ });
+
+ describe('context-specific formatting', () => {
+ it('returns default formatting for Context::Default', () => {
+ const result = getFormattingForEditor('Context::Default');
+
+ expect(result).toEqual(FORMATTING['Context::Default']);
+ });
+
+ it('returns signature formatting for Context::MessageSignature', () => {
+ const result = getFormattingForEditor('Context::MessageSignature');
+
+ expect(result).toEqual(FORMATTING['Context::MessageSignature']);
+ });
+
+ it('returns widget builder formatting for Context::InboxSettings', () => {
+ const result = getFormattingForEditor('Context::InboxSettings');
+
+ expect(result).toEqual(FORMATTING['Context::InboxSettings']);
+ });
+ });
+
+ describe('fallback behavior', () => {
+ it('returns default formatting for unknown channel type', () => {
+ const result = getFormattingForEditor('Channel::Unknown');
+
+ expect(result).toEqual(FORMATTING['Context::Default']);
+ });
+
+ it('returns default formatting for null channel type', () => {
+ const result = getFormattingForEditor(null);
+
+ expect(result).toEqual(FORMATTING['Context::Default']);
+ });
+
+ it('returns default formatting for undefined channel type', () => {
+ const result = getFormattingForEditor(undefined);
+
+ expect(result).toEqual(FORMATTING['Context::Default']);
+ });
+
+ it('returns default formatting for empty string', () => {
+ const result = getFormattingForEditor('');
+
+ expect(result).toEqual(FORMATTING['Context::Default']);
+ });
+ });
+
+ describe('return value structure', () => {
+ it('always returns an object with marks, nodes, and menu properties', () => {
+ const result = getFormattingForEditor('Channel::Email');
+
+ expect(result).toHaveProperty('marks');
+ expect(result).toHaveProperty('nodes');
+ expect(result).toHaveProperty('menu');
+ expect(Array.isArray(result.marks)).toBe(true);
+ expect(Array.isArray(result.nodes)).toBe(true);
+ expect(Array.isArray(result.menu)).toBe(true);
+ });
+ });
+});
+
+describe('Menu positioning helpers', () => {
+ const mockEditorView = {
+ coordsAtPos: vi.fn((pos, bias) => {
+ // Return different coords based on position
+ if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 };
+ return { top: 100, bottom: 120, left: 150, right: 200 };
+ }),
+ };
+
+ const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 };
+
+ describe('getSelectionCoords', () => {
+ it('returns selection coordinates with onTop flag', () => {
+ const selection = { from: 0, to: 10 };
+ const result = getSelectionCoords(mockEditorView, selection, wrapperRect);
+
+ expect(result).toHaveProperty('start');
+ expect(result).toHaveProperty('end');
+ expect(result).toHaveProperty('selTop');
+ expect(result).toHaveProperty('onTop');
+ });
+ });
+
+ describe('getMenuAnchor', () => {
+ it('returns end.left when menu is below selection', () => {
+ const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false };
+ expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150);
+ });
+
+ it('returns start.left for LTR when menu is above and visible', () => {
+ const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true };
+ expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50);
+ });
+
+ it('returns start.right for RTL when menu is above and visible', () => {
+ const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true };
+ expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100);
+ });
+ });
+
+ describe('calculateMenuPosition', () => {
+ it('returns bounded left and top positions', () => {
+ const coords = {
+ start: { top: 100, bottom: 120, left: 50 },
+ end: { top: 100, bottom: 120, left: 150 },
+ selTop: 100,
+ onTop: false,
+ };
+ const result = calculateMenuPosition(coords, wrapperRect, false);
+
+ expect(result).toHaveProperty('left');
+ expect(result).toHaveProperty('top');
+ expect(result).toHaveProperty('width', 300);
+ expect(result.left).toBeGreaterThanOrEqual(0);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index 79d5ebc66..cfb41615b 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -196,7 +196,6 @@
"INSERT_READ_MORE": "Read more",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
- "TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
"TIP_AUDIORECORDER_ICON": "Record audio",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
index bbd0d9000..6a10d0986 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
@@ -27,7 +27,6 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
-import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -81,7 +80,6 @@ export default {
selectedTabIndex: 0,
selectedPortalSlug: '',
showBusinessNameInput: false,
- welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
healthData: null,
isLoadingHealth: false,
healthError: null,
@@ -626,7 +624,7 @@ export default {
)
"
:max-length="255"
- :enabled-menu-options="welcomeTaglineEditorMenuOptions"
+ channel-type="Context::InboxSettings"
/>