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 9c3fa074a..9968fe907 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue @@ -178,8 +178,12 @@ const handleCreateArticle = event => { } .editor-root .has-selection { + .ProseMirror-menubar:not(:has(*)) { + display: none !important; + } + .ProseMirror-menubar { - @apply h-8 rounded-lg !px-2 z-50 bg-n-solid-3 items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak; + @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; top: var(--selection-top, auto) !important; left: var(--selection-left, 0) !important; @@ -187,15 +191,10 @@ const handleCreateArticle = event => { position: absolute !important; .ProseMirror-menuitem { - @apply mr-0; + @apply ltr:mr-0 rtl:ml-0 size-4 flex items-center; .ProseMirror-icon { - @apply p-0 mt-0 !mr-0; - - svg { - width: 20px !important; - height: 20px !important; - } + @apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2; } } diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue index 812da812a..048a99cf8 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue @@ -158,9 +158,11 @@ const activeChild = computed(() => { return rankedPage ?? activeOnPages[0]; } - return navigableChildren.value.find( - child => child.to && route.path.startsWith(resolvePath(child.to)) - ); + return navigableChildren.value.find(child => { + if (!child.to) return false; + const childPath = resolvePath(child.to); + return route.path === childPath || route.path.startsWith(`${childPath}/`); + }); }); const hasActiveChild = computed(() => { diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 976ed3270..72104ff06 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue @@ -8,13 +8,22 @@ import { EditorState, Selection, } from '@chatwoot/prosemirror-schema'; +import { + suggestionsPlugin, + triggerCharacters, +} from '@chatwoot/prosemirror-schema/src/mentions/plugin'; import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image'; +import { toggleMark } from 'prosemirror-commands'; +import { wrapInList } from 'prosemirror-schema-list'; +import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { useAlert } from 'dashboard/composables'; import { useUISettings } from 'dashboard/composables/useUISettings'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; +import SlashCommandMenu from './SlashCommandMenu.vue'; const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB +const SLASH_MENU_OFFSET = 4; const createState = ( content, placeholder, @@ -40,6 +49,7 @@ let editorView = null; let state; export default { + components: { SlashCommandMenu }, mixins: [keyboardEventListenerMixins], props: { modelValue: { type: String, default: '' }, @@ -62,8 +72,15 @@ export default { }, data() { return { - plugins: [imagePastePlugin(this.handleImageUpload)], + plugins: [ + imagePastePlugin(this.handleImageUpload), + this.createSlashPlugin(), + ], isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection + showSlashMenu: false, + slashSearchTerm: '', + slashRange: null, + slashMenuPosition: null, }; }, watch: { @@ -95,6 +112,126 @@ export default { } }, methods: { + createSlashPlugin() { + return suggestionsPlugin({ + matcher: triggerCharacters('/', 0), + suggestionClass: '', + onEnter: args => { + this.showSlashMenu = true; + this.slashRange = args.range; + this.slashSearchTerm = args.text || ''; + this.updateSlashMenuPosition(args.range.from); + return false; + }, + onChange: args => { + this.slashRange = args.range; + this.slashSearchTerm = args.text; + return false; + }, + onExit: () => { + this.slashSearchTerm = ''; + this.showSlashMenu = false; + this.slashMenuPosition = null; + return false; + }, + onKeyDown: ({ event }) => { + return ( + event.keyCode === 13 && + this.showSlashMenu && + this.$refs.slashMenu?.hasItems + ); + }, + }); + }, + updateSlashMenuPosition(pos) { + if (!editorView) return; + const coords = editorView.coordsAtPos(pos); + const editorRect = this.$refs.editor.getBoundingClientRect(); + const isRtl = getComputedStyle(this.$refs.editor).direction === 'rtl'; + this.slashMenuPosition = { + top: coords.bottom - editorRect.top + SLASH_MENU_OFFSET, + ...(isRtl + ? { right: editorRect.right - coords.right } + : { left: coords.left - editorRect.left }), + }; + }, + removeSlashTriggerText() { + if (!editorView || !this.slashRange) return; + const { from, to } = this.slashRange; + editorView.dispatch(editorView.state.tr.delete(from, to)); + state = editorView.state; + }, + executeSlashCommand(actionKey) { + if (!editorView) return; + + this.removeSlashTriggerText(); + + const { schema } = editorView.state; + const commandMap = { + strong: () => + toggleMark(schema.marks.strong)( + editorView.state, + editorView.dispatch + ), + em: () => + toggleMark(schema.marks.em)(editorView.state, editorView.dispatch), + strike: () => + toggleMark(schema.marks.strike)( + editorView.state, + editorView.dispatch + ), + code: () => + toggleMark(schema.marks.code)(editorView.state, editorView.dispatch), + h1: () => + toggleBlockType(schema.nodes.heading, { level: 1 })( + editorView.state, + editorView.dispatch + ), + h2: () => + toggleBlockType(schema.nodes.heading, { level: 2 })( + editorView.state, + editorView.dispatch + ), + h3: () => + toggleBlockType(schema.nodes.heading, { level: 3 })( + editorView.state, + editorView.dispatch + ), + bulletList: () => + wrapInList(schema.nodes.bullet_list)( + editorView.state, + editorView.dispatch + ), + orderedList: () => + wrapInList(schema.nodes.ordered_list)( + editorView.state, + editorView.dispatch + ), + insertTable: () => { + const { table, table_row, table_header, table_cell, paragraph } = + schema.nodes; + const headerCells = [0, 1, 2].map(() => + table_header.createAndFill(null, paragraph.create()) + ); + const dataCells = [0, 1, 2].map(() => + table_cell.createAndFill(null, paragraph.create()) + ); + const headerRow = table_row.create(null, headerCells); + const dataRow = table_row.create(null, dataCells); + const tableNode = table.create(null, [headerRow, dataRow]); + const tr = editorView.state.tr.replaceSelectionWith(tableNode); + editorView.dispatch(tr.scrollIntoView()); + }, + }; + + const command = commandMap[actionKey]; + if (command) { + command(); + state = editorView.state; + this.emitOnChange(); + editorView.focus(); + } + }, contentFromEditor() { if (editorView) { return ArticleMarkdownSerializer.serialize(editorView.state.doc); @@ -262,7 +399,8 @@ export default { // Get the editor's width const editorWidth = editor.offsetWidth; - const menubarWidth = 480; // Menubar width (adjust as needed (px)) + const menubar = editor.querySelector('.ProseMirror-menubar'); + const menubarWidth = menubar ? menubar.scrollWidth : 480; // Get the end position of the selection const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to); @@ -290,7 +428,15 @@ export default {