Merge pull request #265 from frappe/develop

chore: Merge develop to main
This commit is contained in:
Shariq Ansari 2024-07-15 12:49:51 +05:30 committed by GitHub
commit 360d22edd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 368 additions and 147 deletions

View File

@ -132,3 +132,36 @@ def set_as_primary(contact, field, value):
contact.save() contact.save()
return True return True
@frappe.whitelist()
def search_emails(txt: str):
doctype = "Contact"
meta = frappe.get_meta(doctype)
filters = [["Contact", "email_id", "is", "set"]]
if meta.get("fields", {"fieldname": "enabled", "fieldtype": "Check"}):
filters.append([doctype, "enabled", "=", 1])
if meta.get("fields", {"fieldname": "disabled", "fieldtype": "Check"}):
filters.append([doctype, "disabled", "!=", 1])
or_filters = []
search_fields = ["full_name", "email_id", "name"]
if txt:
for f in search_fields:
or_filters.append([doctype, f.strip(), "like", f"%{txt}%"])
results = frappe.get_list(
doctype,
filters=filters,
fields=search_fields,
or_filters=or_filters,
limit_start=0,
limit_page_length=20,
order_by='email_id, full_name, name',
ignore_permissions=False,
as_list=True,
strict=False,
)
return results

View File

@ -9,6 +9,7 @@
"serve": "vite preview" "serve": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tiptap/extension-paragraph": "^2.4.0",
"@twilio/voice-sdk": "^2.10.2", "@twilio/voice-sdk": "^2.10.2",
"@vueuse/core": "^10.3.0", "@vueuse/core": "^10.3.0",
"@vueuse/integrations": "^10.3.0", "@vueuse/integrations": "^10.3.0",

View File

@ -442,7 +442,7 @@
'outgoing_call', 'outgoing_call',
].includes(activity.activity_type), ].includes(activity.activity_type),
'bg-white': ['added', 'removed', 'changed'].includes( 'bg-white': ['added', 'removed', 'changed'].includes(
activity.activity_type activity.activity_type,
), ),
}" }"
> >
@ -528,7 +528,10 @@
<span v-if="activity.data.bcc">{{ activity.data.bcc }}</span> <span v-if="activity.data.bcc">{{ activity.data.bcc }}</span>
</div> </div>
<EmailContent :content="activity.data.content" /> <EmailContent :content="activity.data.content" />
<div class="flex flex-wrap gap-2"> <div
v-if="activity.data?.attachments?.length"
class="flex flex-wrap gap-2"
>
<AttachmentItem <AttachmentItem
v-for="a in activity.data.attachments" v-for="a in activity.data.attachments"
:key="a.file_url" :key="a.file_url"
@ -1102,7 +1105,7 @@ const defaultActions = computed(() => {
}, },
] ]
return actions.filter((action) => return actions.filter((action) =>
action.condition ? action.condition() : true action.condition ? action.condition() : true,
) )
}) })
@ -1120,12 +1123,12 @@ const activities = computed(() => {
} else if (props.title == 'Emails') { } else if (props.title == 'Emails') {
if (!all_activities.data?.versions) return [] if (!all_activities.data?.versions) return []
activities = all_activities.data.versions.filter( activities = all_activities.data.versions.filter(
(activity) => activity.activity_type === 'communication' (activity) => activity.activity_type === 'communication',
) )
} else if (props.title == 'Comments') { } else if (props.title == 'Comments') {
if (!all_activities.data?.versions) return [] if (!all_activities.data?.versions) return []
activities = all_activities.data.versions.filter( activities = all_activities.data.versions.filter(
(activity) => activity.activity_type === 'comment' (activity) => activity.activity_type === 'comment',
) )
} else if (props.title == 'Calls') { } else if (props.title == 'Calls') {
if (!all_activities.data?.calls) return [] if (!all_activities.data?.calls) return []
@ -1338,12 +1341,15 @@ function reply(email, reply_all = false) {
editor.bccEmails = bcc editor.bccEmails = bcc
} }
let repliedMessage = `<blockquote>${message}</blockquote>`
editor.editor editor.editor
.chain() .chain()
.clearContent() .clearContent()
.insertContent(message) .insertContent('<p>.</p>')
.updateAttributes('paragraph', {class:'reply-to-content'})
.insertContent(repliedMessage)
.focus('all') .focus('all')
.setBlockquote()
.insertContentAt(0, { type: 'paragraph' }) .insertContentAt(0, { type: 'paragraph' })
.focus('start') .focus('start')
.run() .run()

View File

@ -12,7 +12,8 @@
<template v-slot:editor="{ editor }"> <template v-slot:editor="{ editor }">
<EditorContent <EditorContent
:class="[ :class="[
editable && 'sm:mx-10 mx-4 max-h-[50vh] overflow-y-auto border-t py-3', editable &&
'sm:mx-10 mx-4 max-h-[50vh] overflow-y-auto border-t py-3',
]" ]"
:editor="editor" :editor="editor"
/> />
@ -37,11 +38,19 @@
<div <div
class="flex justify-between gap-2 overflow-hidden border-t sm:px-10 px-4 py-2.5" class="flex justify-between gap-2 overflow-hidden border-t sm:px-10 px-4 py-2.5"
> >
<div class="flex items-center overflow-x-auto"> <div class="flex gap-1 items-center overflow-x-auto">
<TextEditorFixedMenu <TextEditorBubbleMenu :buttons="textEditorMenuButtons" />
class="-ml-1" <IconPicker
:buttons="textEditorMenuButtons" v-model="emoji"
/> v-slot="{ togglePopover }"
@update:modelValue="() => appendEmoji()"
>
<Button variant="ghost" @click="togglePopover()">
<template #icon>
<SmileIcon class="h-4" />
</template>
</Button>
</IconPicker>
<FileUploader <FileUploader
:upload-args="{ :upload-args="{
doctype: doctype, doctype: doctype,
@ -77,10 +86,12 @@
</TextEditor> </TextEditor>
</template> </template>
<script setup> <script setup>
import IconPicker from '@/components/IconPicker.vue'
import SmileIcon from '@/components/Icons/SmileIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue' import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import AttachmentItem from '@/components/AttachmentItem.vue' import AttachmentItem from '@/components/AttachmentItem.vue'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { TextEditorFixedMenu, TextEditor, FileUploader } from 'frappe-ui' import { TextEditorBubbleMenu, TextEditor, FileUploader } from 'frappe-ui'
import { EditorContent } from '@tiptap/vue-3' import { EditorContent } from '@tiptap/vue-3'
import { ref, computed, defineModel } from 'vue' import { ref, computed, defineModel } from 'vue'
@ -118,11 +129,18 @@ const content = defineModel('content')
const { users: usersList } = usersStore() const { users: usersList } = usersStore()
const textEditor = ref(null) const textEditor = ref(null)
const emoji = ref('')
const editor = computed(() => { const editor = computed(() => {
return textEditor.value.editor return textEditor.value.editor
}) })
function appendEmoji() {
editor.value.commands.insertContent(emoji.value)
editor.value.commands.focus()
emoji.value = ''
}
function removeAttachment(attachment) { function removeAttachment(attachment) {
attachments.value = attachments.value.filter((a) => a !== attachment) attachments.value = attachments.value.filter((a) => a !== attachment)
} }

View File

@ -23,18 +23,6 @@
</template> </template>
</Button> </Button>
</div> </div>
<div v-if="showEmailBox" class="flex gap-1.5">
<Button
:label="__('CC')"
@click="toggleCC()"
:class="[newEmailEditor.cc ? 'bg-gray-300 hover:bg-gray-200' : '']"
/>
<Button
:label="__('BCC')"
@click="toggleBCC()"
:class="[newEmailEditor.bcc ? 'bg-gray-300 hover:bg-gray-200' : '']"
/>
</div>
</div> </div>
<div <div
v-show="showEmailBox" v-show="showEmailBox"
@ -103,7 +91,7 @@ import EmailIcon from '@/components/Icons/EmailIcon.vue'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { call, createResource } from 'frappe-ui' import { call, createResource } from 'frappe-ui'
import { ref, watch, computed, nextTick } from 'vue' import { ref, watch, computed } from 'vue'
const props = defineProps({ const props = defineProps({
doctype: { doctype: {
@ -145,6 +133,7 @@ const signature = createResource({
}) })
function setSignature(editor) { function setSignature(editor) {
if (!signature.data) return
signature.data = signature.data.replace(/\n/g, '<br>') signature.data = signature.data.replace(/\n/g, '<br>')
let emailContent = editor.getHTML() let emailContent = editor.getHTML()
emailContent = emailContent.startsWith('<p></p>') emailContent = emailContent.startsWith('<p></p>')
@ -236,22 +225,6 @@ async function submitComment() {
emit('scroll') emit('scroll')
} }
function toggleCC() {
newEmailEditor.value.cc = !newEmailEditor.value.cc
newEmailEditor.value.cc &&
nextTick(() => {
newEmailEditor.value.ccInput.setFocus()
})
}
function toggleBCC() {
newEmailEditor.value.bcc = !newEmailEditor.value.bcc
newEmailEditor.value.bcc &&
nextTick(() => {
newEmailEditor.value.bccInput.setFocus()
})
}
function toggleEmailBox() { function toggleEmailBox() {
if (showCommentBox.value) { if (showCommentBox.value) {
showCommentBox.value = false showCommentBox.value = false

View File

@ -8,7 +8,7 @@
:label="value" :label="value"
theme="gray" theme="gray"
variant="subtle" variant="subtle"
class="rounded-full" class="rounded"
@keydown.delete.capture.stop="removeLastValue" @keydown.delete.capture.stop="removeLastValue"
> >
<template #suffix> <template #suffix>
@ -133,30 +133,26 @@ watchDebounced(
query, query,
(val) => { (val) => {
val = val || '' val = val || ''
if (text.value === val) return if (text.value === val && options.value?.length) return
text.value = val text.value = val
reload(val) reload(val)
}, },
{ debounce: 300, immediate: true } { debounce: 300, immediate: true },
) )
const filterOptions = createResource({ const filterOptions = createResource({
url: 'frappe.desk.search.search_link', url: 'crm.api.contact.search_emails',
method: 'POST', method: 'POST',
cache: [text.value, 'Contact'], cache: [text.value, 'Contact'],
params: { params: { txt: text.value },
txt: text.value,
doctype: 'Contact',
},
transform: (data) => { transform: (data) => {
let allData = data let allData = data
.filter((c) => {
return c.description.split(', ')[1]
})
.map((option) => { .map((option) => {
let email = option.description.split(', ')[1] let fullName = option[0]
let email = option[1]
let name = option[2]
return { return {
label: option.label || email, label: fullName || name || email,
value: email, value: email,
} }
}) })
@ -177,10 +173,7 @@ const options = computed(() => {
function reload(val) { function reload(val) {
filterOptions.update({ filterOptions.update({
params: { params: { txt: val },
txt: val,
doctype: 'Contact',
},
}) })
filterOptions.reload() filterOptions.reload()
} }

View File

@ -4,9 +4,10 @@
:srcdoc="htmlContent" :srcdoc="htmlContent"
class="prose-f block h-screen max-h-[500px] w-full" class="prose-f block h-screen max-h-[500px] w-full"
style=" style="
height: 40px;
mask-image: linear-gradient( mask-image: linear-gradient(
to bottom, to bottom,
black calc(100% - 30px), black calc(100% - 20px),
transparent 100% transparent 100%
); );
" "
@ -27,6 +28,90 @@ const files = import.meta.globEager('/src/index.css', { query: '?inline' })
const css = files['/src/index.css'].default const css = files['/src/index.css'].default
const iframeRef = ref(null) const iframeRef = ref(null)
const _content = ref(props.content)
const parser = new DOMParser()
const doc = parser.parseFromString(_content.value, 'text/html')
const gmailReplyToContent = doc.querySelectorAll('div.gmail_quote')
const outlookReplyToContent = doc.querySelectorAll('div#appendonsend')
const replyToContent = doc.querySelectorAll('p.reply-to-content')
if (gmailReplyToContent.length) {
_content.value = parseReplyToContent(doc, 'div.gmail_quote', true)
} else if (outlookReplyToContent.length) {
_content.value = parseReplyToContent(doc, 'div#appendonsend')
} else if (replyToContent.length) {
_content.value = parseReplyToContent(doc, 'p.reply-to-content')
}
function parseReplyToContent(doc, selector, forGmail = false) {
function handleAllInstances(doc) {
const replyToContentElements = doc.querySelectorAll(selector)
if (replyToContentElements.length === 0) return
const replyToContentElement = replyToContentElements[0]
replaceReplyToContent(replyToContentElement, forGmail)
handleAllInstances(doc)
}
handleAllInstances(doc)
return doc.body.innerHTML
}
function replaceReplyToContent(replyToContentElement, forGmail) {
if (!replyToContentElement) return
let randomId = Math.random().toString(36).substring(2, 7)
const wrapper = doc.createElement('div')
wrapper.classList.add('replied-content')
const collapseLabel = doc.createElement('label')
collapseLabel.classList.add('collapse')
collapseLabel.setAttribute('for', randomId)
collapseLabel.innerHTML = '...'
wrapper.appendChild(collapseLabel)
const collapseInput = doc.createElement('input')
collapseInput.setAttribute('id', randomId)
collapseInput.setAttribute('class', 'replyCollapser')
collapseInput.setAttribute('type', 'checkbox')
wrapper.appendChild(collapseInput)
if (forGmail) {
const prevSibling = replyToContentElement.previousElementSibling
if (prevSibling && prevSibling.tagName === 'BR') {
prevSibling.remove()
}
let cloned = replyToContentElement.cloneNode(true)
cloned.classList.remove('gmail_quote')
wrapper.appendChild(cloned)
} else {
const allSiblings = Array.from(replyToContentElement.parentElement.children)
const replyToContentIndex = allSiblings.indexOf(replyToContentElement)
const followingSiblings = allSiblings.slice(replyToContentIndex + 1)
if (followingSiblings.length === 0) return
let clonedFollowingSiblings = followingSiblings.map((sibling) =>
sibling.cloneNode(true),
)
const div = doc.createElement('div')
div.append(...clonedFollowingSiblings)
wrapper.append(div)
// Remove all siblings after the reply-to-content element
for (let i = replyToContentIndex + 1; i < allSiblings.length; i++) {
replyToContentElement.parentElement.removeChild(allSiblings[i])
}
}
replyToContentElement.parentElement.replaceChild(
wrapper,
replyToContentElement,
)
}
const htmlContent = ` const htmlContent = `
<!DOCTYPE html> <!DOCTYPE html>
@ -35,6 +120,35 @@ const htmlContent = `
<style> <style>
${css} ${css}
.replied-content .collapse {
margin: 10px 0 10px 0;
visibility: visible;
cursor: pointer;
display: flex;
font-size: larger;
font-weight: 700;
height: 12px;
line-height: 0.1;
background: #e8eaed;
width: 23px;
justify-content: center;
border-radius: 5px;
}
.replied-content .collapse:hover {
background: #dadce0;
}
.replied-content .collapse + input {
display: none;
}
.replied-content .collapse + input + div {
display: none;
}
.replied-content .collapse + input:checked + div {
display: block;
}
.email-content { .email-content {
word-break: break-word; word-break: break-word;
} }
@ -110,7 +224,7 @@ const htmlContent = `
</style> </style>
</head> </head>
<body> <body>
<div ref="emailContentRef" class="email-content prose-f">${props.content}</div> <div ref="emailContentRef" class="email-content prose-f">${_content.value}</div>
</body> </body>
</html> </html>
` `
@ -120,7 +234,18 @@ watch(iframeRef, (iframe) => {
iframe.onload = () => { iframe.onload = () => {
const emailContent = const emailContent =
iframe.contentWindow.document.querySelector('.email-content') iframe.contentWindow.document.querySelector('.email-content')
iframe.style.height = emailContent.offsetHeight + 25 + 'px' let parent = emailContent.closest('html')
iframe.style.height = parent.offsetHeight + 1 + 'px'
let replyCollapsers = emailContent.querySelectorAll('.replyCollapser')
if (replyCollapsers.length) {
replyCollapsers.forEach((replyCollapser) => {
replyCollapser.addEventListener('change', () => {
iframe.style.height = parent.offsetHeight + 1 + 'px'
})
})
}
} }
} }
}) })

View File

@ -1,68 +1,90 @@
<template> <template>
<TextEditor <TextEditor
ref="textEditor" ref="textEditor"
:editor-class="['prose-sm max-w-none', editable && 'min-h-[7rem]']" :editor-class="[
'prose-sm max-w-none',
editable && 'min-h-[7rem]',
'[&_p.reply-to-content]:hidden',
]"
:content="content" :content="content"
@change="editable ? (content = $event) : null" @change="editable ? (content = $event) : null"
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }" :starterkit-options="{
heading: { levels: [2, 3, 4, 5, 6] },
paragraph: false,
}"
:placeholder="placeholder" :placeholder="placeholder"
:editable="editable" :editable="editable"
:extensions="[CustomParagraph]"
> >
<template #top> <template #top>
<div class="sm:mx-10 mx-4 flex items-center gap-2 border-t py-2.5"> <div class="flex flex-col gap-3">
<span class="text-xs text-gray-500">{{ __('SUBJECT') }}:</span> <div class="sm:mx-10 mx-4 flex items-center gap-2 border-t pt-2.5">
<TextInput <span class="text-xs text-gray-500">{{ __('TO') }}:</span>
class="flex-1 border-none bg-white hover:bg-white focus:border-none focus:!shadow-none focus-visible:!ring-0" <MultiselectInput
v-model="subject" class="flex-1"
/> v-model="toEmails"
</div> :validate="validateEmail"
<div :error-message="
class="sm:mx-10 mx-4 flex items-center gap-2 border-t py-2.5" (value) => __('{0} is an invalid email address', [value])
:class="[cc || bcc ? 'border-b' : '']" "
> />
<span class="text-xs text-gray-500">{{ __('TO') }}:</span> <div class="flex gap-1.5">
<MultiselectInput <Button
class="flex-1" :label="__('CC')"
v-model="toEmails" variant="ghost"
:validate="validateEmail" @click="toggleCC()"
:error-message=" :class="[
(value) => __('{0} is an invalid email address', [value]) cc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500',
" ]"
/> />
</div> <Button
<div :label="__('BCC')"
v-if="cc" variant="ghost"
class="sm:mx-10 mx-4 flex items-center gap-2 py-2.5" @click="toggleBCC()"
:class="bcc ? 'border-b' : ''" :class="[
> bcc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500',
<span class="text-xs text-gray-500">{{ __('CC') }}:</span> ]"
<MultiselectInput />
ref="ccInput" </div>
class="flex-1" </div>
v-model="ccEmails" <div v-if="cc" class="sm:mx-10 mx-4 flex items-center gap-2">
:validate="validateEmail" <span class="text-xs text-gray-500">{{ __('CC') }}:</span>
:error-message=" <MultiselectInput
(value) => __('{0} is an invalid email address', [value]) ref="ccInput"
" class="flex-1"
/> v-model="ccEmails"
</div> :validate="validateEmail"
<div v-if="bcc" class="sm:mx-10 mx-4 flex items-center gap-2 py-2.5"> :error-message="
<span class="text-xs text-gray-500">{{ __('BCC') }}:</span> (value) => __('{0} is an invalid email address', [value])
<MultiselectInput "
ref="bccInput" />
class="flex-1" </div>
v-model="bccEmails" <div v-if="bcc" class="sm:mx-10 mx-4 flex items-center gap-2">
:validate="validateEmail" <span class="text-xs text-gray-500">{{ __('BCC') }}:</span>
:error-message=" <MultiselectInput
(value) => __('{0} is an invalid email address', [value]) ref="bccInput"
" class="flex-1"
/> v-model="bccEmails"
:validate="validateEmail"
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
/>
</div>
<div class="sm:mx-10 mx-4 flex items-center gap-2 pb-2.5">
<span class="text-xs text-gray-500">{{ __('SUBJECT') }}:</span>
<TextInput
class="flex-1 border-none bg-white hover:bg-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
v-model="subject"
/>
</div>
</div> </div>
</template> </template>
<template v-slot:editor="{ editor }"> <template v-slot:editor="{ editor }">
<EditorContent <EditorContent
:class="[ :class="[
editable && 'sm:mx-10 mx-4 max-h-[35vh] overflow-y-auto border-t py-3', editable &&
'sm:mx-10 mx-4 max-h-[35vh] overflow-y-auto border-t py-3',
]" ]"
:editor="editor" :editor="editor"
/> />
@ -87,37 +109,43 @@
<div <div
class="flex justify-between gap-2 overflow-hidden border-t sm:px-10 px-4 py-2.5" class="flex justify-between gap-2 overflow-hidden border-t sm:px-10 px-4 py-2.5"
> >
<div class="flex items-center overflow-x-auto"> <div class="flex gap-1 items-center overflow-x-auto">
<TextEditorFixedMenu <TextEditorBubbleMenu :buttons="textEditorMenuButtons" />
class="-ml-1" <IconPicker
:buttons="textEditorMenuButtons" v-model="emoji"
/> v-slot="{ togglePopover }"
<div class="flex gap-1"> @update:modelValue="() => appendEmoji()"
<FileUploader >
:upload-args="{ <Button variant="ghost" @click="togglePopover()">
doctype: doctype,
docname: modelValue.name,
private: true,
}"
@success="(f) => attachments.push(f)"
>
<template #default="{ openFileSelector }">
<Button variant="ghost" @click="openFileSelector()">
<template #icon>
<AttachmentIcon class="h-4" />
</template>
</Button>
</template>
</FileUploader>
<Button
variant="ghost"
@click="showEmailTemplateSelectorModal = true"
>
<template #icon> <template #icon>
<EmailIcon class="h-4" /> <SmileIcon class="h-4" />
</template> </template>
</Button> </Button>
</div> </IconPicker>
<FileUploader
:upload-args="{
doctype: doctype,
docname: modelValue.name,
private: true,
}"
@success="(f) => attachments.push(f)"
>
<template #default="{ openFileSelector }">
<Button variant="ghost" @click="openFileSelector()">
<template #icon>
<AttachmentIcon class="h-4" />
</template>
</Button>
</template>
</FileUploader>
<Button
variant="ghost"
@click="showEmailTemplateSelectorModal = true"
>
<template #icon>
<EmailIcon class="h-4" />
</template>
</Button>
</div> </div>
<div class="mt-2 flex items-center justify-end space-x-2 sm:mt-0"> <div class="mt-2 flex items-center justify-end space-x-2 sm:mt-0">
<Button v-bind="discardButtonProps || {}" :label="__('Discard')" /> <Button v-bind="discardButtonProps || {}" :label="__('Discard')" />
@ -139,15 +167,18 @@
</template> </template>
<script setup> <script setup>
import IconPicker from '@/components/IconPicker.vue'
import SmileIcon from '@/components/Icons/SmileIcon.vue'
import EmailIcon from '@/components/Icons/EmailIcon.vue' import EmailIcon from '@/components/Icons/EmailIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue' import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import AttachmentItem from '@/components/AttachmentItem.vue' import AttachmentItem from '@/components/AttachmentItem.vue'
import MultiselectInput from '@/components/Controls/MultiselectInput.vue' import MultiselectInput from '@/components/Controls/MultiselectInput.vue'
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue' import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
import { TextEditorFixedMenu, TextEditor, FileUploader, call } from 'frappe-ui' import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui'
import { validateEmail } from '@/utils' import { validateEmail } from '@/utils'
import Paragraph from '@tiptap/extension-paragraph'
import { EditorContent } from '@tiptap/vue-3' import { EditorContent } from '@tiptap/vue-3'
import { ref, computed, defineModel } from 'vue' import { ref, computed, defineModel, nextTick } from 'vue'
const props = defineProps({ const props = defineProps({
placeholder: { placeholder: {
@ -180,6 +211,24 @@ const props = defineProps({
}, },
}) })
const CustomParagraph = Paragraph.extend({
addAttributes() {
return {
class: {
default: null,
renderHTML: (attributes) => {
if (!attributes.class) {
return {}
}
return {
class: `${attributes.class}`,
}
},
},
}
},
})
const modelValue = defineModel() const modelValue = defineModel()
const attachments = defineModel('attachments') const attachments = defineModel('attachments')
const content = defineModel('content') const content = defineModel('content')
@ -187,6 +236,7 @@ const content = defineModel('content')
const textEditor = ref(null) const textEditor = ref(null)
const cc = ref(false) const cc = ref(false)
const bcc = ref(false) const bcc = ref(false)
const emoji = ref('')
const subject = ref(props.subject) const subject = ref(props.subject)
const toEmails = ref(modelValue.value.email ? [modelValue.value.email] : []) const toEmails = ref(modelValue.value.email ? [modelValue.value.email] : [])
@ -211,7 +261,7 @@ async function applyEmailTemplate(template) {
{ {
template_name: template.name, template_name: template.name,
doc: modelValue.value, doc: modelValue.value,
} },
) )
if (template.subject) { if (template.subject) {
@ -225,6 +275,22 @@ async function applyEmailTemplate(template) {
showEmailTemplateSelectorModal.value = false showEmailTemplateSelectorModal.value = false
} }
function appendEmoji() {
editor.value.commands.insertContent(emoji.value)
editor.value.commands.focus()
emoji.value = ''
}
function toggleCC() {
cc.value = !cc.value
cc.value && nextTick(() => ccInput.value.setFocus())
}
function toggleBCC() {
bcc.value = !bcc.value
bcc.value && nextTick(() => bccInput.value.setFocus())
}
defineExpose({ defineExpose({
editor, editor,
subject, subject,
@ -233,8 +299,6 @@ defineExpose({
toEmails, toEmails,
ccEmails, ccEmails,
bccEmails, bccEmails,
ccInput,
bccInput,
}) })
const textEditorMenuButtons = [ const textEditorMenuButtons = [

View File

@ -5,6 +5,7 @@
:list="columns" :list="columns"
item-key="column" item-key="column"
@end="updateColumn" @end="updateColumn"
:delay="isTouchScreenDevice() ? 200 : 0"
class="flex sm:mx-2.5 mx-2 pb-3.5" class="flex sm:mx-2.5 mx-2 pb-3.5"
> >
<template #item="{ element: column }"> <template #item="{ element: column }">
@ -73,6 +74,7 @@
item-key="name" item-key="name"
class="flex flex-col gap-3.5 flex-1" class="flex flex-col gap-3.5 flex-1"
@end="updateColumn" @end="updateColumn"
:delay="isTouchScreenDevice() ? 200 : 0"
:data-column="column.column.name" :data-column="column.column.name"
> >
<template #item="{ element: fields }"> <template #item="{ element: fields }">
@ -166,6 +168,7 @@
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue' import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import NestedPopover from '@/components/NestedPopover.vue' import NestedPopover from '@/components/NestedPopover.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue' import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import { isTouchScreenDevice } from '@/utils'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { Dropdown } from 'frappe-ui' import { Dropdown } from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'

View File

@ -1808,6 +1808,11 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.2.6.tgz#0e66d2ce21116e43fd006961c42f187ee5e5beab" resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.2.6.tgz#0e66d2ce21116e43fd006961c42f187ee5e5beab"
integrity sha512-M2rM3pfzziUb7xS9x2dANCokO89okbqg5IqU4VPkZhk0Mfq9czyCatt58TYkAsE3ccsGhdTYtFBTDeKBtsHUqg== integrity sha512-M2rM3pfzziUb7xS9x2dANCokO89okbqg5IqU4VPkZhk0Mfq9czyCatt58TYkAsE3ccsGhdTYtFBTDeKBtsHUqg==
"@tiptap/extension-paragraph@^2.4.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.4.0.tgz#5b9aea8775937b327bbe6754be12ae3144fb09ff"
integrity sha512-+yse0Ow67IRwcACd9K/CzBcxlpr9OFnmf0x9uqpaWt1eHck1sJnti6jrw5DVVkyEBHDh/cnkkV49gvctT/NyCw==
"@tiptap/extension-placeholder@^2.0.3": "@tiptap/extension-placeholder@^2.0.3":
version "2.2.6" version "2.2.6"
resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.2.6.tgz#7cb63e398a5301d1e132d4145daef3acb87e7dc5" resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.2.6.tgz#7cb63e398a5301d1e132d4145daef3acb87e7dc5"