refactor(editor): Remove unncessary props and simplify code for tiptap editor, update all editors for the same

This commit is contained in:
Abhinav Raut
2025-06-06 03:02:11 +05:30
parent d532a99771
commit cc1432b3e4
11 changed files with 62 additions and 228 deletions

View File

@@ -30,25 +30,23 @@
<Button
size="sm"
variant="ghost"
@click.prevent="isBold = !isBold"
:active="isBold"
:class="{ 'bg-gray-200 dark:bg-secondary': isBold }"
@click.prevent="editor?.chain().focus().toggleBold().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bold') }"
>
<Bold size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="isItalic = !isItalic"
:active="isItalic"
:class="{ 'bg-gray-200 dark:bg-secondary': isItalic }"
@click.prevent="editor?.chain().focus().toggleItalic().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('italic') }"
>
<Italic size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="toggleBulletList"
@click.prevent="editor?.chain().focus().toggleBulletList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }"
>
<List size="14" />
@@ -57,7 +55,7 @@
<Button
size="sm"
variant="ghost"
@click.prevent="toggleOrderedList"
@click.prevent="editor?.chain().focus().toggleOrderedList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('orderedList') }"
>
<ListOrdered size="14" />
@@ -91,7 +89,7 @@
</template>
<script setup>
import { ref, watch, watchEffect, onUnmounted, computed } from 'vue'
import { ref, watch, onUnmounted } from 'vue'
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
import {
ChevronDown,
@@ -121,21 +119,14 @@ import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
const selectedText = defineModel('selectedText', { default: '' })
const textContent = defineModel('textContent')
const htmlContent = defineModel('htmlContent')
const isBold = defineModel('isBold')
const isItalic = defineModel('isItalic')
const cursorPosition = defineModel('cursorPosition', { default: 0 })
const textContent = defineModel('textContent', { default: '' })
const htmlContent = defineModel('htmlContent', { default: '' })
const showLinkInput = ref(false)
const linkUrl = ref('')
const props = defineProps({
placeholder: String,
contentToSet: String,
setInlineImage: Object,
insertContent: String,
clearContent: Boolean,
autoFocus: {
type: Boolean,
default: true
@@ -150,8 +141,6 @@ const emit = defineEmits(['send', 'aiPromptSelected'])
const emitPrompt = (key) => emit('aiPromptSelected', key)
const getSelectionText = (from, to, doc) => doc.textBetween(from, to)
// To preseve the table styling in emails, need to set the table style inline.
// Created these custom extensions to set the table style inline.
const CustomTable = Table.extend({
@@ -160,7 +149,7 @@ const CustomTable = Table.extend({
...this.parent?.(),
style: {
parseHTML: (element) =>
(element.getAttribute('style') || '') + ' border: 1px solid #dee2e6 !important; width: 100%; margin:0; table-layout: fixed; border-collapse: collapse; position:relative; border-radius: 0.25rem;'
(element.getAttribute('style') || '') + '; border: 1px solid #dee2e6 !important; width: 100%; margin:0; table-layout: fixed; border-collapse: collapse; position:relative; border-radius: 0.25rem;'
}
}
}
@@ -173,7 +162,7 @@ const CustomTableCell = TableCell.extend({
style: {
parseHTML: (element) =>
(element.getAttribute('style') || '') +
' border: 1px solid #dee2e6 !important; box-sizing: border-box !important; min-width: 1em !important; padding: 6px 8px !important; vertical-align: top !important;'
'; border: 1px solid #dee2e6 !important; box-sizing: border-box !important; min-width: 1em !important; padding: 6px 8px !important; vertical-align: top !important;'
}
}
}
@@ -186,26 +175,27 @@ const CustomTableHeader = TableHeader.extend({
style: {
parseHTML: (element) =>
(element.getAttribute('style') || '') +
' background-color: #f8f9fa !important; color: #212529 !important; font-weight: bold !important; text-align: left !important; border: 1px solid #dee2e6 !important; padding: 6px 8px !important;'
'; background-color: #f8f9fa !important; color: #212529 !important; font-weight: bold !important; text-align: left !important; border: 1px solid #dee2e6 !important; padding: 6px 8px !important;'
}
}
}
})
const editorConfig = computed(() => ({
const isInternalUpdate = ref(false)
const editor = useEditor({
extensions: [
StarterKit.configure(),
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
Placeholder.configure({ placeholder: () => props.placeholder }),
Link,
CustomTable.configure({
resizable: false
}),
CustomTable.configure({ resizable: false }),
TableRow,
CustomTableCell,
CustomTableHeader
],
autofocus: props.autoFocus,
content: htmlContent.value,
editorProps: {
attributes: { class: 'outline-none' },
handleKeyDown: (view, event) => {
@@ -213,110 +203,30 @@ const editorConfig = computed(() => ({
emit('send')
return true
}
if (event.ctrlKey && event.key.toLowerCase() === 'b') {
// Prevent outer listeners
event.stopPropagation()
return false
}
}
}
}))
const editor = ref(
useEditor({
...editorConfig.value,
content: htmlContent.value,
onSelectionUpdate: ({ editor }) => {
const { from, to } = editor.state.selection
selectedText.value = getSelectionText(from, to, editor.state.doc)
},
onUpdate: ({ editor }) => {
htmlContent.value = editor.getHTML()
textContent.value = editor.getText()
cursorPosition.value = editor.state.selection.from
},
onCreate: ({ editor }) => {
if (cursorPosition.value) {
editor.commands.setTextSelection(cursorPosition.value)
}
}
})
)
watchEffect(() => {
const editorInstance = editor.value
if (!editorInstance) return
isBold.value = editorInstance.isActive('bold')
isItalic.value = editorInstance.isActive('italic')
})
watchEffect(() => {
const editorInstance = editor.value
if (!editorInstance) return
if (isBold.value !== editorInstance.isActive('bold')) {
isBold.value
? editorInstance.chain().focus().setBold().run()
: editorInstance.chain().focus().unsetBold().run()
}
if (isItalic.value !== editorInstance.isActive('italic')) {
isItalic.value
? editorInstance.chain().focus().setItalic().run()
: editorInstance.chain().focus().unsetItalic().run()
},
// To update state when user types.
onUpdate: ({ editor }) => {
isInternalUpdate.value = true
htmlContent.value = editor.getHTML()
textContent.value = editor.getText()
isInternalUpdate.value = false
}
})
watch(
() => props.contentToSet,
(newContentData) => {
if (!newContentData) return
try {
const parsedData = JSON.parse(newContentData)
const content = parsedData.content
if (content === '') {
editor.value?.commands.clearContent()
} else {
editor.value?.commands.setContent(content, true)
}
editor.value?.commands.focus()
} catch (e) {
console.error('Error parsing content data', e)
htmlContent,
(newContent) => {
if (!isInternalUpdate.value && editor.value && newContent !== editor.value.getHTML()) {
editor.value.commands.setContent(newContent || '', false)
textContent.value = editor.value.getText()
editor.value.commands.focus()
}
}
)
watch(cursorPosition, (newPos, oldPos) => {
if (editor.value && newPos !== oldPos && newPos !== editor.value.state.selection.from) {
editor.value.commands.setTextSelection(newPos)
}
})
watch(
() => props.clearContent,
() => {
if (!props.clearContent) return
editor.value?.commands.clearContent()
editor.value?.commands.focus()
// `onUpdate` is not called when clearing content, so need to reset the content here.
htmlContent.value = ''
textContent.value = ''
cursorPosition.value = 0
}
)
watch(
() => props.setInlineImage,
(val) => {
if (val) {
editor.value?.commands.setImage({
src: val.src,
alt: val.alt,
title: val.title
})
}
}
},
{ immediate: true }
)
// Insert content at cursor position when insertContent prop changes.
watch(
() => props.insertContent,
(val) => {
@@ -328,18 +238,6 @@ onUnmounted(() => {
editor.value?.destroy()
})
const toggleBulletList = () => {
if (editor.value) {
editor.value.chain().focus().toggleBulletList().run()
}
}
const toggleOrderedList = () => {
if (editor.value) {
editor.value.chain().focus().toggleOrderedList().run()
}
}
const openLinkModal = () => {
if (editor.value?.isActive('link')) {
linkUrl.value = editor.value.getAttributes('link').href

View File

@@ -138,8 +138,7 @@ export const accountNavItems = [
{
titleKey: 'globals.terms.profile',
href: '/account/profile',
description: 'Update your profile'
}
},
]
export const contactNavItems = [

View File

@@ -248,18 +248,10 @@ const fetchBusinessHours = async () => {
})
businessHours.value = response.data.data
} catch (error) {
// If unauthorized (no permission), show a toast message.
if (error.response.status === 403) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('admin.businessHours.unauthorized')
})
} else {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}

View File

@@ -19,7 +19,7 @@
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
:placeholder="t('editor.newLine')"
/>
</div>
</FormControl>

View File

@@ -2,7 +2,9 @@
<div class="w-full space-y-6 pb-8 relative">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<span class="text-xl font-semibold text-gray-900 dark:text-foreground">{{ $t('globals.terms.note', 2) }}</span>
<span class="text-xl font-semibold text-gray-900 dark:text-foreground">
{{ $t('globals.terms.note', 2) }}
</span>
<Button
variant="outline"
size="sm"
@@ -27,7 +29,7 @@
<Editor
v-model:htmlContent="newNote"
@update:htmlContent="(value) => (newNote = value)"
:placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
:placeholder="t('editor.newLine') + t('editor.send')"
/>
</div>
<div class="flex justify-end space-x-3 pt-2">
@@ -64,7 +66,9 @@
</AvatarFallback>
</Avatar>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-foreground">{{ note.first_name }} {{ note.last_name }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-foreground">
{{ note.first_name }} {{ note.last_name }}
</p>
<p class="text-xs text-muted-foreground flex items-center">
<ClockIcon class="h-3 w-3 mr-1 inline-block opacity-70" />
{{ formatDate(note.created_at) }}
@@ -115,7 +119,9 @@
<div class="rounded-full bg-gray-100 dark:bg-foreground p-4 mb-2">
<MessageSquareIcon class="text-gray-400 dark:text-background" size="25" />
</div>
<h3 class="mt-2 text-base font-medium text-gray-900 dark:text-foreground">{{ $t('contact.notes.empty') }}</h3>
<h3 class="mt-2 text-base font-medium text-gray-900 dark:text-foreground">
{{ $t('contact.notes.empty') }}
</h3>
<p class="mt-1 text-sm text-muted-foreground max-w-sm mx-auto">
{{ $t('contact.notes.help') }}
</p>

View File

@@ -161,9 +161,7 @@
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:contentToSet="contentToSet"
:placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
:clearContent="clearEditorContent"
:placeholder="t('editor.newLine') + t('editor.cmdK')"
:insertContent="insertContent"
:autoFocus="false"
class="w-full flex-1 overflow-y-auto p-2 box min-h-0"
@@ -199,6 +197,7 @@
<ReplyBoxMenuBar
:handleFileUpload="handleFileUpload"
@emojiSelect="handleEmojiSelect"
:showSendButton="false"
/>
<Button type="submit" :disabled="isDisabled" :isLoading="loading">
{{ $t('globals.buttons.submit') }}
@@ -266,9 +265,6 @@ const emailQuery = ref('')
const conversationStore = useConversationStore()
const macroStore = useMacroStore()
let timeoutId = null
const contentToSet = ref('')
const clearEditorContent = ref(false)
const insertContent = ref('')
const handleEmojiSelect = (emoji) => {
@@ -420,11 +416,7 @@ const createConversation = form.handleSubmit(async (values) => {
watch(
() => conversationStore.getMacro('new-conversation').id,
() => {
// Setting timestamp, so the same macro can be set again.
contentToSet.value = JSON.stringify({
content: conversationStore.getMacro('new-conversation').message_content,
timestamp: Date.now()
})
form.setFieldValue('content', conversationStore.getMacro('new-conversation').message_content)
},
{ deep: true }
)

View File

@@ -4,7 +4,7 @@
<div
v-for="action in actions"
:key="action.type"
class="flex items-center border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group gap-2"
class="flex items-center border bg-background border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group gap-2"
>
<div class="flex items-center space-x-2 px-2">
<component

View File

@@ -53,13 +53,8 @@
:isSending="isSending"
:uploadingFiles="uploadingFiles"
:clearEditorContent="clearEditorContent"
:contentToSet="contentToSet"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:cursorPosition="cursorPosition"
v-model:to="to"
v-model:cc="cc"
v-model:bcc="bcc"
@@ -88,14 +83,9 @@
:isSending="isSending"
:uploadingFiles="uploadingFiles"
:clearEditorContent="clearEditorContent"
:contentToSet="contentToSet"
:uploadedFiles="mediaFiles"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:cursorPosition="cursorPosition"
v-model:to="to"
v-model:cc="cc"
v-model:bcc="bcc"
@@ -181,11 +171,6 @@ const emailErrors = ref([])
const aiPrompts = ref([])
const htmlContent = ref('')
const textContent = ref('')
const selectedText = ref('')
const isBold = ref(false)
const isItalic = ref(false)
const cursorPosition = ref(0)
const contentToSet = ref('')
onMounted(async () => {
await fetchAiPrompts()
@@ -218,10 +203,7 @@ const handleAiPromptSelected = async (key) => {
prompt_key: key,
content: textContent.value
})
contentToSet.value = JSON.stringify({
content: resp.data.data.replace(/\n/g, '<br>'),
timestamp: Date.now()
})
htmlContent.value = resp.data.data.replace(/\n/g, '<br>')
} catch (error) {
// Check if user needs to enter OpenAI API key and has permission to do so.
if (error.response?.status === 400 && userStore.can('ai:manage')) {
@@ -348,11 +330,7 @@ const processSend = async () => {
watch(
() => conversationStore.getMacro('reply').id,
() => {
// Setting timestamp, so the same macro can be set again.
contentToSet.value = JSON.stringify({
content: conversationStore.getMacro('reply').message_content,
timestamp: Date.now()
})
htmlContent.value = conversationStore.getMacro('reply').message_content
},
{ deep: true }
)

View File

@@ -84,21 +84,14 @@
<!-- Main tiptap editor -->
<div class="flex-grow flex flex-col overflow-hidden">
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
v-model:cursorPosition="cursorPosition"
:placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
:autoFocus="true"
@aiPromptSelected="handleAiPromptSelected"
@send="handleSend"
/>
</div>
@@ -124,14 +117,9 @@
class="mt-1 shrink-0"
:isFullscreen="isFullscreen"
:handleFileUpload="handleFileUpload"
:isBold="isBold"
:isItalic="isItalic"
:isSending="isSending"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
:showSendButton="true"
@emojiSelect="handleEmojiSelect"
/>
</div>
@@ -162,10 +150,6 @@ const showBcc = defineModel('showBcc', { default: false })
const emailErrors = defineModel('emailErrors', { default: () => [] })
const htmlContent = defineModel('htmlContent', { default: '' })
const textContent = defineModel('textContent', { default: '' })
const selectedText = defineModel('selectedText', { default: '' })
const isBold = defineModel('isBold', { default: false })
const isItalic = defineModel('isItalic', { default: false })
const cursorPosition = defineModel('cursorPosition', { default: 0 })
const macroStore = useMacroStore()
const props = defineProps({
@@ -185,14 +169,6 @@ const props = defineProps({
type: Array,
required: true
},
clearEditorContent: {
type: Boolean,
required: true
},
contentToSet: {
type: String,
default: null
},
uploadedFiles: {
type: Array,
required: false,
@@ -212,9 +188,7 @@ const emit = defineEmits([
const conversationStore = useConversationStore()
const emitter = useEmitter()
const { t } = useI18n()
const insertContent = ref(null)
const setInlineImage = ref(null)
const toggleBcc = async () => {
showBcc.value = !showBcc.value
@@ -231,14 +205,6 @@ const toggleFullscreen = () => {
emit('toggleFullscreen')
}
const toggleBold = () => {
isBold.value = !isBold.value
}
const toggleItalic = () => {
isItalic.value = !isItalic.value
}
const enableSend = computed(() => {
return (
(textContent.value.trim().length > 0 ||

View File

@@ -65,7 +65,10 @@ defineProps({
isSending: Boolean,
enableSend: Boolean,
handleSend: Function,
showSendButton: Boolean,
showSendButton: {
type: Boolean,
default: true
},
handleFileUpload: Function,
handleInlineImageUpload: Function
})

View File

@@ -640,7 +640,7 @@ export const useConversationStore = defineStore('conversation', () => {
}
/** Macros for new conversation or open conversation **/
/** Macros set for new conversation or an open conversation **/
async function setMacro (macro, context) {
macros.value[context] = macro
}