Files
mail/src/components/MessageComposer.vue
T
2026-06-20 15:41:47 -04:00

294 lines
7.2 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'
import { useEditor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
import Placeholder from '@tiptap/extension-placeholder'
import { EntityObject } from '@MailManager/models/entity'
import type { CollectionObject, MessageAddressObject } from '@MailManager/models'
import { useMailOperationsStore } from '@/stores/mailOperationsStore'
import { useMailCompositionStore } from '@/stores/mailCompositionStore'
import { ComposerMode } from '@/types/composer'
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
import ComposerSender from '@/components/composer/ComposerSender.vue'
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
import ComposerAttachments from '@/components/composer/ComposerAttachments.vue'
import ComposerEditor from '@/components/composer/ComposerEditor.vue'
// Props
interface Props {
mode: ComposerMode
source?: EntityObject | MessageAddressObject | null
folder?: CollectionObject | null
}
const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
close: []
}>()
const mailOperationsStore = useMailOperationsStore()
const compositionStore = useMailCompositionStore()
const {
composerSending: sending,
} = storeToRefs(mailOperationsStore)
const {
activeDraft,
saving,
stageStatus,
} = storeToRefs(compositionStore)
// State
const showCc = ref(false)
const showBcc = ref(false)
const applyingDraftToEditor = ref(false)
const sender = computed({
get: () => activeDraft.value?.sender ?? null,
set: value => {
if (value) {
compositionStore.updateSender(value)
}
},
})
const to = computed({
get: () => activeDraft.value?.message.to ?? [],
set: value => compositionStore.updateRecipients('to', value),
})
const cc = computed({
get: () => activeDraft.value?.message.cc ?? [],
set: value => compositionStore.updateRecipients('cc', value),
})
const bcc = computed({
get: () => activeDraft.value?.message.bcc ?? [],
set: value => compositionStore.updateRecipients('bcc', value),
})
const subject = computed({
get: () => activeDraft.value?.message.subject ?? '',
set: value => compositionStore.updateSubject(value),
})
const attachments = computed(() => activeDraft.value?.attachments ?? {})
// Initialize Tiptap editor
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
}),
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Placeholder.configure({
placeholder: 'Write your message...',
}),
],
content: '',
editorProps: {
attributes: {
class: 'tiptap-editor pa-4',
},
},
})
watch(
[() => props.mode, () => props.source, () => editor.value],
([, , currentEditor]) => {
if (!currentEditor) {
return
}
compositionStore.openDraft(props.mode, props.source)
showCc.value = (activeDraft.value?.message.cc.length ?? 0) > 0
showBcc.value = (activeDraft.value?.message.bcc.length ?? 0) > 0
applyingDraftToEditor.value = true
currentEditor.commands.setContent(activeDraft.value?.message.body.html || '')
applyingDraftToEditor.value = false
},
{ immediate: true },
)
// Computed
const canSend = computed(() => {
return to.value.length > 0 && subject.value.trim().length > 0
})
// Watch editor content changes
watch(editor, currentEditor => {
if (!currentEditor) {
return
}
const handleUpdate = () => {
if (applyingDraftToEditor.value) {
return
}
compositionStore.updateBody({
html: currentEditor.getHTML(),
text: currentEditor.getText(),
})
}
currentEditor.on('update', handleUpdate)
return () => {
currentEditor.off('update', handleUpdate)
}
})
// Cleanup
onBeforeUnmount(() => {
void compositionStore.flushSave()
editor.value?.destroy()
})
// Handlers
const handleClose = async () => {
await compositionStore.closeDraft()
emit('close')
}
const handleSend = async () => {
await compositionStore.sendDraft()
}
const handleAttach = async (files: File[]) => {
await compositionStore.addAttachments(files)
}
const handleDetach = async (identifier: string) => {
await compositionStore.removeAttachment(identifier)
}
const toggleCc = () => {
showCc.value = !showCc.value
}
const toggleBcc = () => {
showBcc.value = !showBcc.value
}
// Toolbar actions
const toggleBold = () => editor.value?.chain().focus().toggleBold().run()
const toggleItalic = () => editor.value?.chain().focus().toggleItalic().run()
const toggleUnderline = () => editor.value?.chain().focus().toggleUnderline().run()
const toggleBulletList = () => editor.value?.chain().focus().toggleBulletList().run()
const toggleOrderedList = () => editor.value?.chain().focus().toggleOrderedList().run()
const setLink = () => {
const url = window.prompt('Enter URL:')
if (url) {
editor.value?.chain().focus().setLink({ href: url }).run()
}
}
const removeLink = () => editor.value?.chain().focus().unsetLink().run()
const toggleLink = () => {
if (isActive('link')) {
removeLink()
return
}
setLink()
}
const isActive = (name: string, attrs?: any) => {
return editor.value?.isActive(name, attrs) || false
}
</script>
<template>
<div class="message-composer">
<ComposerToolbar
:mode="mode"
:status="stageStatus"
:can-send="canSend"
:sending="sending"
@close="handleClose"
@send="handleSend"
/>
<div class="composer-content">
<ComposerSender
v-model="sender"
:options="compositionStore.senderIdentities"
/>
<ComposerRecipients
:to="to"
:cc="cc"
:bcc="bcc"
:subject="subject"
:show-cc="showCc"
:show-bcc="showBcc"
@update:to="to = $event"
@update:cc="cc = $event"
@update:bcc="bcc = $event"
@update:subject="subject = $event"
@toggle:cc="toggleCc"
@toggle:bcc="toggleBcc"
/>
<v-divider />
<ComposerAttachments
v-if="Object.keys(attachments).length > 0"
:attachments="attachments"
@remove="handleDetach"
/>
<v-divider v-if="Object.keys(attachments).length > 0" />
<ComposerEditor
:editor="editor"
:is-bold-active="isActive('bold')"
:is-italic-active="isActive('italic')"
:is-underline-active="isActive('underline')"
:is-bullet-list-active="isActive('bulletList')"
:is-ordered-list-active="isActive('orderedList')"
:is-link-active="isActive('link')"
@bold="toggleBold"
@italic="toggleItalic"
@underline="toggleUnderline"
@bullet-list="toggleBulletList"
@ordered-list="toggleOrderedList"
@link="toggleLink"
@attach="handleAttach"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.message-composer {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.composer-toolbar {
flex-shrink: 0;
border-bottom: 1px solid rgb(var(--v-border-color));
}
.composer-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>