diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 64f6136c2..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); @@ -291,7 +428,15 @@ export default {