476 lines
14 KiB
Vue
476 lines
14 KiB
Vue
<script>
|
|
import {
|
|
fullSchema,
|
|
buildEditor,
|
|
EditorView,
|
|
ArticleMarkdownSerializer,
|
|
ArticleMarkdownTransformer,
|
|
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,
|
|
// eslint-disable-next-line default-param-last
|
|
plugins = [],
|
|
// eslint-disable-next-line default-param-last
|
|
methods = {},
|
|
enabledMenuOptions
|
|
) => {
|
|
return EditorState.create({
|
|
doc: new ArticleMarkdownTransformer(fullSchema).parse(content),
|
|
plugins: buildEditor({
|
|
schema: fullSchema,
|
|
placeholder,
|
|
methods,
|
|
plugins,
|
|
enabledMenuOptions,
|
|
}),
|
|
});
|
|
};
|
|
|
|
let editorView = null;
|
|
let state;
|
|
|
|
export default {
|
|
components: { SlashCommandMenu },
|
|
mixins: [keyboardEventListenerMixins],
|
|
props: {
|
|
modelValue: { type: String, default: '' },
|
|
editorId: { type: String, default: '' },
|
|
placeholder: { type: String, default: '' },
|
|
enabledMenuOptions: { type: Array, default: () => [] },
|
|
autofocus: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
},
|
|
emits: ['blur', 'input', 'update:modelValue', 'keyup', 'focus', 'keydown'],
|
|
setup() {
|
|
const { uiSettings, updateUISettings } = useUISettings();
|
|
|
|
return {
|
|
uiSettings,
|
|
updateUISettings,
|
|
};
|
|
},
|
|
data() {
|
|
return {
|
|
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: {
|
|
modelValue(newValue = '') {
|
|
if (newValue !== this.contentFromEditor()) {
|
|
this.reloadState();
|
|
}
|
|
},
|
|
editorId() {
|
|
this.reloadState();
|
|
},
|
|
},
|
|
|
|
created() {
|
|
state = createState(
|
|
this.modelValue || '',
|
|
this.placeholder,
|
|
this.plugins,
|
|
{ onImageUpload: this.openFileBrowser },
|
|
this.enabledMenuOptions
|
|
);
|
|
},
|
|
mounted() {
|
|
this.createEditorView();
|
|
|
|
editorView.updateState(state);
|
|
if (this.autofocus) {
|
|
this.focusEditorInputField();
|
|
}
|
|
},
|
|
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);
|
|
}
|
|
return '';
|
|
},
|
|
openFileBrowser() {
|
|
this.$refs.imageUploadInput.click();
|
|
},
|
|
async handleImageUpload(url) {
|
|
try {
|
|
const fileUrl = await this.$store.dispatch(
|
|
'articles/uploadExternalImage',
|
|
{
|
|
portalSlug: this.$route.params.portalSlug,
|
|
url,
|
|
}
|
|
);
|
|
|
|
return fileUrl;
|
|
} catch (error) {
|
|
useAlert(
|
|
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.UN_AUTHORIZED_ERROR')
|
|
);
|
|
return '';
|
|
}
|
|
},
|
|
onFileChange() {
|
|
const file = this.$refs.imageUploadInput.files[0];
|
|
|
|
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
|
this.uploadImageToStorage(file);
|
|
} else {
|
|
useAlert(
|
|
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR_FILE_SIZE', {
|
|
size: MAXIMUM_FILE_UPLOAD_SIZE,
|
|
})
|
|
);
|
|
}
|
|
|
|
this.$refs.imageUploadInput.value = '';
|
|
},
|
|
async uploadImageToStorage(file) {
|
|
try {
|
|
const fileUrl = await this.$store.dispatch('articles/attachImage', {
|
|
portalSlug: this.$route.params.portalSlug,
|
|
file,
|
|
});
|
|
|
|
if (fileUrl) {
|
|
this.onImageUploadStart(fileUrl);
|
|
}
|
|
} catch (error) {
|
|
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR'));
|
|
}
|
|
},
|
|
onImageUploadStart(fileUrl) {
|
|
const { selection } = editorView.state;
|
|
const from = selection.from;
|
|
const node = editorView.state.schema.nodes.image.create({
|
|
src: fileUrl,
|
|
});
|
|
const paragraphNode = editorView.state.schema.node('paragraph');
|
|
if (node) {
|
|
// Insert the image and the caption wrapped inside a paragraph
|
|
const tr = editorView.state.tr
|
|
.replaceSelectionWith(paragraphNode)
|
|
.insert(from + 1, node);
|
|
|
|
editorView.dispatch(tr.scrollIntoView());
|
|
this.focusEditorInputField();
|
|
}
|
|
},
|
|
reloadState() {
|
|
state = createState(
|
|
this.modelValue || '',
|
|
this.placeholder,
|
|
this.plugins,
|
|
{ onImageUpload: this.openFileBrowser },
|
|
this.enabledMenuOptions
|
|
);
|
|
editorView.updateState(state);
|
|
this.focusEditorInputField();
|
|
},
|
|
createEditorView() {
|
|
editorView = new EditorView(this.$refs.editor, {
|
|
state: state,
|
|
dispatchTransaction: tx => {
|
|
state = state.apply(tx);
|
|
editorView.updateState(state);
|
|
if (tx.docChanged) {
|
|
this.emitOnChange();
|
|
}
|
|
this.checkSelection(state);
|
|
},
|
|
handleDOMEvents: {
|
|
keyup: this.onKeyup,
|
|
focus: this.onFocus,
|
|
blur: this.onBlur,
|
|
keydown: this.onKeydown,
|
|
paste: (view, event) => {
|
|
const data = event.clipboardData.files;
|
|
if (data.length > 0) {
|
|
data.forEach(file => {
|
|
// Check if the file is an image
|
|
if (file.type.includes('image')) {
|
|
this.uploadImageToStorage(file);
|
|
}
|
|
});
|
|
event.preventDefault();
|
|
}
|
|
},
|
|
},
|
|
});
|
|
},
|
|
handleKeyEvents() {},
|
|
focusEditorInputField() {
|
|
const { tr } = editorView.state;
|
|
const selection = Selection.atEnd(tr.doc);
|
|
|
|
editorView.dispatch(tr.setSelection(selection));
|
|
editorView.focus();
|
|
},
|
|
emitOnChange() {
|
|
this.$emit('update:modelValue', this.contentFromEditor());
|
|
this.$emit('input', this.contentFromEditor());
|
|
},
|
|
onKeyup() {
|
|
this.$emit('keyup');
|
|
},
|
|
onKeydown() {
|
|
this.$emit('keydown');
|
|
},
|
|
onBlur() {
|
|
this.$emit('blur');
|
|
},
|
|
onFocus() {
|
|
this.$emit('focus');
|
|
},
|
|
checkSelection(editorState) {
|
|
const { from, to } = editorState.selection;
|
|
// Check if there's a selection (from and to are different)
|
|
const hasSelection = from !== to;
|
|
// If the selection state is the same as the previous state, do nothing
|
|
if (hasSelection === this.isTextSelected) return;
|
|
// Update the selection state
|
|
this.isTextSelected = hasSelection;
|
|
|
|
const { editor } = this.$refs;
|
|
|
|
// Toggle the 'has-selection' class based on whether there's a selection
|
|
editor.classList.toggle('has-selection', hasSelection);
|
|
// If there's a selection, update the menubar position
|
|
if (hasSelection) this.setMenubarPosition(editorState);
|
|
},
|
|
setMenubarPosition(editorState) {
|
|
if (!editorState.selection) return;
|
|
|
|
// Get the start and end positions of the selection
|
|
const { from, to } = editorState.selection;
|
|
const { editor } = this.$refs;
|
|
// Get the editor's position relative to the viewport
|
|
const { left: editorLeft, top: editorTop } =
|
|
editor.getBoundingClientRect();
|
|
|
|
// Get the editor's width
|
|
const editorWidth = editor.offsetWidth;
|
|
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);
|
|
// Get the start position of the selection
|
|
const { left: startLeft } = editorView.coordsAtPos(from);
|
|
|
|
// Calculate the top position for the menubar (10px below the selection)
|
|
const top = endBottom - editorTop + 10;
|
|
// Calculate the left position for the menubar
|
|
// This centers the menubar on the selection while keeping it within the editor's bounds
|
|
const left = Math.max(
|
|
0,
|
|
Math.min(
|
|
(startLeft + endRight) / 2 - editorLeft,
|
|
editorWidth - menubarWidth
|
|
)
|
|
);
|
|
// Set the CSS custom properties for positioning the menubar
|
|
editor.style.setProperty('--selection-top', `${top}px`);
|
|
editor.style.setProperty('--selection-left', `${left}px`);
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="editor-root editor--article relative">
|
|
<SlashCommandMenu
|
|
v-if="showSlashMenu"
|
|
ref="slashMenu"
|
|
:search-key="slashSearchTerm"
|
|
:enabled-menu-options="enabledMenuOptions"
|
|
:position="slashMenuPosition"
|
|
@select-action="executeSlashCommand"
|
|
/>
|
|
<input
|
|
ref="imageUploadInput"
|
|
type="file"
|
|
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
|
hidden
|
|
@change="onFileChange"
|
|
/>
|
|
<div ref="editor" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
@import '@chatwoot/prosemirror-schema/src/styles/article.scss';
|
|
|
|
.ProseMirror-menubar-wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
> .ProseMirror {
|
|
padding: 0;
|
|
word-break: break-word;
|
|
}
|
|
}
|
|
|
|
.editor-root {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.ProseMirror-woot-style {
|
|
min-height: 5rem;
|
|
max-height: 7.5rem;
|
|
overflow: auto;
|
|
}
|
|
</style>
|