feat: Markdown editor support (#1657)
Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
committed by
GitHub
parent
58fad2561d
commit
54f15b73d3
@@ -26,3 +26,4 @@
|
||||
|
||||
@import 'plugins/multiselect';
|
||||
@import 'plugins/dropdown';
|
||||
@import '@chatwoot/prosemirror-schema/src/woot-editor.css';
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div ref="editor" class="editor-root"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
import {
|
||||
schema,
|
||||
defaultMarkdownParser,
|
||||
defaultMarkdownSerializer,
|
||||
} from 'prosemirror-markdown';
|
||||
import { wootWriterSetup } from '@chatwoot/prosemirror-schema';
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
|
||||
const createState = (content, placeholder) =>
|
||||
EditorState.create({
|
||||
doc: defaultMarkdownParser.parse(content),
|
||||
plugins: wootWriterSetup({ schema, placeholder }),
|
||||
});
|
||||
|
||||
export default {
|
||||
name: 'WootMessageEditor',
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lastValue: null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value(newValue) {
|
||||
if (newValue !== this.lastValue) {
|
||||
this.state = createState(newValue, this.placeholder);
|
||||
this.view.updateState(this.state);
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.state = createState(this.value, this.placeholder);
|
||||
},
|
||||
mounted() {
|
||||
this.view = new EditorView(this.$refs.editor, {
|
||||
state: this.state,
|
||||
dispatchTransaction: tx => {
|
||||
this.state = this.state.apply(tx);
|
||||
this.view.updateState(this.state);
|
||||
this.lastValue = defaultMarkdownSerializer.serialize(this.state.doc);
|
||||
this.$emit('input', this.lastValue);
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keyup: () => {
|
||||
this.onKeyup();
|
||||
},
|
||||
focus: () => {
|
||||
this.onFocus();
|
||||
},
|
||||
blur: () => {
|
||||
this.onBlur();
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
resetTyping() {
|
||||
this.$emit('typing-off');
|
||||
this.idleTimer = null;
|
||||
},
|
||||
turnOffIdleTimer() {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
}
|
||||
},
|
||||
onKeyup() {
|
||||
if (!this.idleTimer) {
|
||||
this.$emit('typing-on');
|
||||
}
|
||||
this.turnOffIdleTimer();
|
||||
this.idleTimer = setTimeout(
|
||||
() => this.resetTyping(),
|
||||
TYPING_INDICATOR_IDLE_TIME
|
||||
);
|
||||
},
|
||||
onBlur() {
|
||||
this.turnOffIdleTimer();
|
||||
this.resetTyping();
|
||||
this.$emit('blur');
|
||||
},
|
||||
onFocus() {
|
||||
this.$emit('focus');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.ProseMirror-menubar-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .ProseMirror {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ProseMirror-woot-style {
|
||||
min-height: 8rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,17 @@
|
||||
<template>
|
||||
<div class="bottom-box" :class="wrapClass">
|
||||
<div class="left-wrap">
|
||||
<button class="button clear button--emoji" @click="toggleEmojiPicker">
|
||||
<button
|
||||
class="button clear button--emoji"
|
||||
:title="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
|
||||
@click="toggleEmojiPicker"
|
||||
>
|
||||
<emoji-or-icon icon="ion-happy-outline" emoji="😊" />
|
||||
</button>
|
||||
<button
|
||||
v-if="showAttachButton"
|
||||
class="button clear button--emoji button--upload"
|
||||
:title="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
>
|
||||
<file-upload
|
||||
:size="4096 * 4096"
|
||||
@@ -16,6 +21,14 @@
|
||||
<emoji-or-icon icon="ion-android-attach" emoji="📎" />
|
||||
</file-upload>
|
||||
</button>
|
||||
<button
|
||||
v-if="enableRichEditor"
|
||||
class="button clear button--emoji"
|
||||
:title="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
||||
@click="toggleFormatMode"
|
||||
>
|
||||
<emoji-or-icon icon="ion-quote" emoji="🖊️" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="right-wrap">
|
||||
<button
|
||||
@@ -70,6 +83,18 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
setFormatMode: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
isFormatMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableRichEditor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isNote() {
|
||||
@@ -90,6 +115,11 @@ export default {
|
||||
return this.showFileUpload || this.isNote;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleFormatMode() {
|
||||
this.setFormatMode(!this.isFormatMode);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -108,34 +138,14 @@ export default {
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
padding: var(--space-one) var(--space-slab);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
&:hover {
|
||||
background: var(--w-300);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: white;
|
||||
}
|
||||
|
||||
&.button--emoji {
|
||||
font-size: var(--font-size-small);
|
||||
padding: var(--space-small);
|
||||
border-radius: 9px;
|
||||
background: var(--b-50);
|
||||
border: 1px solid var(--color-border-light);
|
||||
margin-right: var(--space-small);
|
||||
|
||||
&:hover {
|
||||
background: var(--b-200);
|
||||
}
|
||||
}
|
||||
|
||||
&.button--note {
|
||||
background: var(--y-800);
|
||||
color: white;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
:on-click="emojiOnClick"
|
||||
/>
|
||||
<resizable-text-area
|
||||
v-if="!isFormatMode"
|
||||
ref="messageInput"
|
||||
v-model="message"
|
||||
class="input"
|
||||
@@ -30,6 +31,17 @@
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<woot-message-editor
|
||||
v-else
|
||||
v-model="message"
|
||||
class="input"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:min-height="4"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="hasAttachments" class="attachment-preview-box">
|
||||
<attachment-preview
|
||||
@@ -46,6 +58,9 @@
|
||||
:show-emoji-picker="showEmojiPicker"
|
||||
:on-send="sendMessage"
|
||||
:is-send-disabled="isReplyButtonDisabled"
|
||||
:set-format-mode="setFormatMode"
|
||||
:is-format-mode="isFormatMode"
|
||||
:enable-rich-editor="isRichEditorEnabled"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,6 +76,7 @@ import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
|
||||
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel';
|
||||
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
|
||||
import {
|
||||
isEscape,
|
||||
isEnter,
|
||||
@@ -77,6 +93,7 @@ export default {
|
||||
AttachmentPreview,
|
||||
ReplyTopPanel,
|
||||
ReplyBottomPanel,
|
||||
WootMessageEditor,
|
||||
},
|
||||
mixins: [clickaway, inboxMixin],
|
||||
props: {
|
||||
@@ -94,6 +111,7 @@ export default {
|
||||
attachedFiles: [],
|
||||
isUploading: false,
|
||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||
isFormatMode: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -180,6 +198,13 @@ export default {
|
||||
hasAttachments() {
|
||||
return this.attachedFiles.length;
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
return (
|
||||
this.isAWebWidgetInbox ||
|
||||
this.isAnEmailChannel ||
|
||||
this.replyType === REPLY_EDITOR_MODES.NOTE
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation) {
|
||||
@@ -222,7 +247,9 @@ export default {
|
||||
this.hideEmojiPicker();
|
||||
this.hideCannedResponse();
|
||||
} else if (isEnter(e)) {
|
||||
if (!hasPressedShift(e)) {
|
||||
const shouldSendMessage =
|
||||
!this.isFormatMode && !hasPressedShift(e) && this.isFocused;
|
||||
if (shouldSendMessage) {
|
||||
e.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
@@ -335,6 +362,9 @@ export default {
|
||||
|
||||
return messagePayload;
|
||||
},
|
||||
setFormatMode(value) {
|
||||
this.isFormatMode = value;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -41,7 +41,10 @@
|
||||
"PRIVATE_NOTE": "Private Note",
|
||||
"SEND": "Send",
|
||||
"CREATE": "Add Note",
|
||||
"TWEET": "Tweet"
|
||||
"TWEET": "Tweet",
|
||||
"TIP_FORMAT_ICON": "Show rich text editor",
|
||||
"TIP_EMOJI_ICON": "Show emoji selector",
|
||||
"TIP_ATTACH_ICON": "Attach files"
|
||||
},
|
||||
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
|
||||
"CHANGE_STATUS": "Conversation status changed",
|
||||
|
||||
Reference in New Issue
Block a user