Apply Mentions everywhere (#595)
* variables and mentions * fix lint * add missing changes * fix tests * update quilly, fix bugs * fix lint * apply fixes * apply fixes * Fix MentionParser * Apply Mentions everywhere * Fix MentionParserTest * Small refactoring * Fixing quill import issues * Polished email integration, added customer sender mail * Add missing changes * improve migration command --------- Co-authored-by: Frank <csskfaves@gmail.com> Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
199
client/components/forms/MentionInput.vue
Normal file
199
client/components/forms/MentionInput.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<InputWrapper v-bind="inputWrapperProps">
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<MentionDropdown
|
||||
:state="mentionState"
|
||||
:mentions="mentions"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
ref="editableDiv"
|
||||
:contenteditable="!disabled"
|
||||
class="mention-input"
|
||||
:style="inputStyle"
|
||||
:class="[
|
||||
theme.default.input,
|
||||
theme.default.borderRadius,
|
||||
theme.default.spacing.horizontal,
|
||||
theme.default.spacing.vertical,
|
||||
theme.default.fontSize,
|
||||
{
|
||||
'!ring-red-500 !ring-2 !border-transparent': hasError,
|
||||
'!cursor-not-allowed dark:!bg-gray-600 !bg-gray-200': disabled,
|
||||
},
|
||||
'pr-12'
|
||||
]"
|
||||
:placeholder="placeholder"
|
||||
@input="onInput"
|
||||
/>
|
||||
<UButton
|
||||
type="button"
|
||||
color="white"
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 px-2"
|
||||
icon="i-heroicons-at-symbol-16-solid"
|
||||
@click="openMentionDropdown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template
|
||||
v-if="$slots.help"
|
||||
#help
|
||||
>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="$slots.error"
|
||||
#error
|
||||
>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</InputWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
import MentionDropdown from './components/MentionDropdown.vue'
|
||||
const props = defineProps({
|
||||
...inputProps,
|
||||
mentions: { type: Array, default: () => [] },
|
||||
disableMention: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { compVal, inputStyle, hasValidation, hasError, inputWrapperProps } = useFormInput(props, { emit })
|
||||
const editableDiv = ref(null)
|
||||
const savedRange = ref(null)
|
||||
const subscriptionModalStore = useSubscriptionModalStore()
|
||||
|
||||
const mentionState = ref({
|
||||
open: false,
|
||||
onInsert: (mention) => {
|
||||
insertMention(mention)
|
||||
mentionState.value.open = false
|
||||
},
|
||||
onCancel: () => {
|
||||
mentionState.value.open = false
|
||||
restoreSelection()
|
||||
},
|
||||
})
|
||||
const createMentionSpan = (mention) => {
|
||||
const mentionSpan = document.createElement('span')
|
||||
mentionSpan.setAttribute('mention-field-id', mention.field.id)
|
||||
mentionSpan.setAttribute('mention-field-name', mention.field.name)
|
||||
mentionSpan.setAttribute('mention-fallback', mention.fallback || '')
|
||||
mentionSpan.setAttribute('contenteditable', 'false')
|
||||
mentionSpan.setAttribute('mention', 'true')
|
||||
mentionSpan.textContent = mention.field.name.length > 25 ? `${mention.field.name.slice(0, 25)}...` : mention.field.name
|
||||
return mentionSpan
|
||||
}
|
||||
const insertMention = (mention) => {
|
||||
const mentionSpan = createMentionSpan(mention)
|
||||
restoreSelection()
|
||||
const range = window.getSelection().getRangeAt(0)
|
||||
// Insert the mention span
|
||||
range.insertNode(mentionSpan)
|
||||
|
||||
// Move the cursor after the inserted mention
|
||||
range.setStartAfter(mentionSpan)
|
||||
range.collapse(true)
|
||||
// Apply the new selection
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
// Ensure the editableDiv is focused
|
||||
editableDiv.value.focus()
|
||||
updateCompVal()
|
||||
}
|
||||
const openMentionDropdown = () => {
|
||||
if (props.disableMention) {
|
||||
subscriptionModalStore.setModalContent('Upgrade to Pro', 'Upgrade to Pro to use mentions')
|
||||
subscriptionModalStore.openModal()
|
||||
return
|
||||
}
|
||||
|
||||
saveSelection()
|
||||
if (!savedRange.value) {
|
||||
// If no previous selection, move cursor to the end
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editableDiv.value)
|
||||
range.collapse(false)
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
savedRange.value = range
|
||||
}
|
||||
mentionState.value.open = true
|
||||
}
|
||||
const saveSelection = () => {
|
||||
const selection = window.getSelection()
|
||||
if (selection.rangeCount > 0) {
|
||||
savedRange.value = selection.getRangeAt(0)
|
||||
}
|
||||
}
|
||||
const restoreSelection = () => {
|
||||
if (savedRange.value) {
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(savedRange.value)
|
||||
editableDiv.value.focus()
|
||||
}
|
||||
}
|
||||
const updateCompVal = () => {
|
||||
compVal.value = editableDiv.value.innerHTML
|
||||
}
|
||||
const onInput = () => {
|
||||
updateCompVal()
|
||||
}
|
||||
onMounted(() => {
|
||||
if (compVal.value) {
|
||||
editableDiv.value.innerHTML = compVal.value
|
||||
}
|
||||
})
|
||||
watch(compVal, (newVal) => {
|
||||
if (editableDiv.value && editableDiv.value.innerHTML !== newVal) {
|
||||
editableDiv.value.innerHTML = newVal
|
||||
}
|
||||
})
|
||||
defineExpose({
|
||||
editableDiv,
|
||||
compVal,
|
||||
mentionState,
|
||||
openMentionDropdown,
|
||||
onInput,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mention-input {
|
||||
min-height: 1.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.mention-input:empty::before {
|
||||
content: attr(placeholder);
|
||||
color: #9ca3af;
|
||||
}
|
||||
.mention-input span[mention] {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
</style>
|
||||
@@ -4,12 +4,8 @@
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<VueEditor
|
||||
:id="id ? id : name"
|
||||
ref="editor"
|
||||
v-model="compVal"
|
||||
:disabled="disabled ? true : null"
|
||||
:placeholder="placeholder"
|
||||
<div
|
||||
class="rich-editor resize-y"
|
||||
:class="[
|
||||
{
|
||||
'!ring-red-500 !ring-2 !border-transparent': hasError,
|
||||
@@ -17,12 +13,23 @@
|
||||
},
|
||||
theme.RichTextAreaInput.input,
|
||||
theme.RichTextAreaInput.borderRadius,
|
||||
theme.default.fontSize,
|
||||
]"
|
||||
:editor-options="editorOptions"
|
||||
:editor-toolbar="editorToolbar"
|
||||
class="rich-editor resize-y"
|
||||
:style="inputStyle"
|
||||
/>
|
||||
:style="{
|
||||
'--font-size': theme.default.fontSize
|
||||
}"
|
||||
>
|
||||
<QuillyEditor
|
||||
:id="id ? id : name"
|
||||
ref="editor"
|
||||
:key="id+placeholder"
|
||||
v-model="compVal"
|
||||
:options="quillOptions"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
:style="inputStyle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
@@ -30,59 +37,72 @@
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
|
||||
<MentionDropdown
|
||||
v-if="enableMentions && mentionState"
|
||||
:state="mentionState"
|
||||
:mentions="mentions"
|
||||
/>
|
||||
</InputWrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Quill, VueEditor } from 'vue3-editor'
|
||||
<script setup>
|
||||
import Quill from 'quill'
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
|
||||
Quill.imports['formats/link'].PROTOCOL_WHITELIST.push('notion')
|
||||
|
||||
export default {
|
||||
name: 'RichTextAreaInput',
|
||||
components: { InputWrapper, VueEditor },
|
||||
|
||||
props: {
|
||||
...inputProps,
|
||||
editorOptions: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {
|
||||
formats: [
|
||||
'bold',
|
||||
'color',
|
||||
'font',
|
||||
'italic',
|
||||
'link',
|
||||
'underline',
|
||||
'header',
|
||||
'indent',
|
||||
'list'
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
editorToolbar: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [
|
||||
[{ header: 1 }, { header: 2 }],
|
||||
['bold', 'italic', 'underline', 'link'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ color: [] }]
|
||||
]
|
||||
}
|
||||
}
|
||||
import QuillyEditor from './components/QuillyEditor.vue'
|
||||
import MentionDropdown from './components/MentionDropdown.vue'
|
||||
import registerMentionExtension from '~/lib/quill/quillMentionExtension.js'
|
||||
const props = defineProps({
|
||||
...inputProps,
|
||||
editorOptions: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
enableMentions: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
mentions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { compVal, inputStyle, hasError, inputWrapperProps } = useFormInput(props, { emit })
|
||||
const editor = ref(null)
|
||||
const mentionState = ref(null)
|
||||
// Move the mention extension registration to onMounted
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
if (props.enableMentions && !Quill.imports['blots/mention']) {
|
||||
mentionState.value = registerMentionExtension(Quill)
|
||||
}
|
||||
|
||||
const quillOptions = computed(() => {
|
||||
const defaultOptions = {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ 'header': 1 }, { 'header': 2 }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
['link'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ color: [] }],
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
const mergedOptions = { ...defaultOptions, ...props.editorOptions, modules: { ...defaultOptions.modules, ...props.editorOptions.modules } }
|
||||
|
||||
if (props.enableMentions) {
|
||||
mergedOptions.modules.mention = true
|
||||
if (!mergedOptions.modules.toolbar) {
|
||||
mergedOptions.modules.toolbar = []
|
||||
}
|
||||
mergedOptions.modules.toolbar.push(['mention'])
|
||||
}
|
||||
|
||||
return mergedOptions
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -91,18 +111,22 @@ export default {
|
||||
border-bottom: 0px !important;
|
||||
border-right: 0px !important;
|
||||
border-left: 0px !important;
|
||||
|
||||
font-size: var(--font-size);
|
||||
.ql-editor {
|
||||
min-height: 100px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ql-toolbar {
|
||||
border-top: 0px !important;
|
||||
border-right: 0px !important;
|
||||
border-left: 0px !important;
|
||||
}
|
||||
|
||||
.ql-header {
|
||||
@apply rounded-md;
|
||||
}
|
||||
.ql-editor.ql-blank:before {
|
||||
@apply text-gray-400 dark:text-gray-500 not-italic;
|
||||
}
|
||||
.ql-snow .ql-toolbar .ql-picker-item.ql-selected,
|
||||
.ql-snow .ql-toolbar .ql-picker-item:hover,
|
||||
.ql-snow .ql-toolbar .ql-picker-label.ql-active,
|
||||
@@ -120,4 +144,21 @@ export default {
|
||||
@apply text-nt-blue;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
.ql-mention {
|
||||
padding-top: 0px !important;
|
||||
margin-top: -5px !important;
|
||||
}
|
||||
.ql-mention::after {
|
||||
content: '@';
|
||||
font-size: 16px;
|
||||
}
|
||||
.rich-editor, .mention-input {
|
||||
span[mention] {
|
||||
@apply inline-flex items-center align-baseline leading-tight text-sm relative bg-blue-100 text-blue-800 border border-blue-200 rounded-md px-1 py-0.5 mx-0.5;
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
29
client/components/forms/TextBlock.vue
Normal file
29
client/components/forms/TextBlock.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
v-html="processedContent"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
mentionsAllowed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
formData: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
const processedContent = computed(() => {
|
||||
return useParseMention(props.content, props.mentionsAllowed, props.form, props.formData)
|
||||
})
|
||||
</script>
|
||||
105
client/components/forms/components/FormSubmissionFormatter.js
vendored
Normal file
105
client/components/forms/components/FormSubmissionFormatter.js
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
import { format, parseISO } from 'date-fns'
|
||||
|
||||
export class FormSubmissionFormatter {
|
||||
constructor(form, formData) {
|
||||
this.form = form
|
||||
this.formData = formData
|
||||
this.createLinks = false
|
||||
this.outputStringsOnly = false
|
||||
this.showGeneratedIds = false
|
||||
this.datesIsoFormat = false
|
||||
}
|
||||
|
||||
setCreateLinks() {
|
||||
this.createLinks = true
|
||||
return this
|
||||
}
|
||||
|
||||
setShowGeneratedIds() {
|
||||
this.showGeneratedIds = true
|
||||
return this
|
||||
}
|
||||
|
||||
setOutputStringsOnly() {
|
||||
this.outputStringsOnly = true
|
||||
return this
|
||||
}
|
||||
|
||||
setUseIsoFormatForDates() {
|
||||
this.datesIsoFormat = true
|
||||
return this
|
||||
}
|
||||
|
||||
getFormattedData() {
|
||||
const formattedData = {}
|
||||
|
||||
this.form.properties.forEach(field => {
|
||||
if (!this.formData[field.id] && !this.fieldGeneratesId(field)) {
|
||||
return
|
||||
}
|
||||
|
||||
const value = this.formatFieldValue(field, this.formData[field.id])
|
||||
formattedData[field.id] = value
|
||||
})
|
||||
|
||||
return formattedData
|
||||
}
|
||||
|
||||
formatFieldValue(field, value) {
|
||||
switch (field.type) {
|
||||
case 'url':
|
||||
return this.createLinks ? `<a href="${value}">${value}</a>` : value
|
||||
case 'email':
|
||||
return this.createLinks ? `<a href="mailto:${value}">${value}</a>` : value
|
||||
case 'checkbox':
|
||||
return value ? 'Yes' : 'No'
|
||||
case 'date':
|
||||
return this.formatDateValue(field, value)
|
||||
case 'people':
|
||||
return this.formatPeopleValue(value)
|
||||
case 'multi_select':
|
||||
return this.outputStringsOnly ? value.join(', ') : value
|
||||
case 'relation':
|
||||
return this.formatRelationValue(value)
|
||||
default:
|
||||
return Array.isArray(value) && this.outputStringsOnly ? value.join(', ') : value
|
||||
}
|
||||
}
|
||||
|
||||
formatDateValue(field, value) {
|
||||
if (this.datesIsoFormat) {
|
||||
return Array.isArray(value)
|
||||
? { start_date: value[0], end_date: value[1] || null }
|
||||
: value
|
||||
}
|
||||
|
||||
const dateFormat = (field.date_format || 'dd/MM/yyyy') === 'dd/MM/yyyy' ? 'dd/MM/yyyy' : 'MM/dd/yyyy'
|
||||
const timeFormat = field.with_time ? (field.time_format === 24 ? 'HH:mm' : 'h:mm a') : ''
|
||||
const fullFormat = `${dateFormat}${timeFormat ? ' ' + timeFormat : ''}`
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const start = format(parseISO(value[0]), fullFormat)
|
||||
const end = value[1] ? format(parseISO(value[1]), fullFormat) : null
|
||||
return end ? `${start} - ${end}` : start
|
||||
}
|
||||
|
||||
return format(parseISO(value), fullFormat)
|
||||
}
|
||||
|
||||
formatPeopleValue(value) {
|
||||
if (!value) return []
|
||||
const people = Array.isArray(value) ? value : [value]
|
||||
return this.outputStringsOnly ? people.map(p => p.name).join(', ') : people
|
||||
}
|
||||
|
||||
formatRelationValue(value) {
|
||||
if (!value) return []
|
||||
const relations = Array.isArray(value) ? value : [value]
|
||||
const formatted = relations.map(r => r.title || 'Untitled')
|
||||
return this.outputStringsOnly ? formatted.join(', ') : formatted
|
||||
}
|
||||
|
||||
fieldGeneratesId(field) {
|
||||
return this.showGeneratedIds && (field.generates_auto_increment_id || field.generates_uuid)
|
||||
}
|
||||
}
|
||||
116
client/components/forms/components/MentionDropdown.vue
Normal file
116
client/components/forms/components/MentionDropdown.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<UPopover
|
||||
ref="popover"
|
||||
v-model:open="open"
|
||||
class="h-0"
|
||||
@close="cancel"
|
||||
>
|
||||
<span class="hidden" />
|
||||
<template #panel>
|
||||
<div class="p-2 max-h-[300px] flex flex-col">
|
||||
<div class="flex items-center border-b -mx-2 px-2">
|
||||
<div class="font-semibold w-1/2 mb-2 flex-grow">
|
||||
Insert Mention
|
||||
</div>
|
||||
<input
|
||||
v-model="fallbackValue"
|
||||
class="p-1 mb-2 text-sm w-1/2 border rounded-md hover:bg-gray-50"
|
||||
placeholder="Fallback value"
|
||||
>
|
||||
</div>
|
||||
<div class="overflow-scroll pt-2">
|
||||
<div class="w-full max-w-xs mb-2">
|
||||
<div class="text-sm text-gray-500 mb-1">
|
||||
Select a field
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="field in filteredMentions"
|
||||
:key="field.id"
|
||||
class="flex items-center p-2 rounded-md cursor-pointer hover:bg-gray-100"
|
||||
:class="{ 'bg-blue-50 border border-blue-100 inset-0': selectedField?.id === field.id, 'border border-transparent': selectedField?.id !== field.id }"
|
||||
@click="selectField(field)"
|
||||
@dblclick="selectField(field, true)"
|
||||
>
|
||||
<BlockTypeIcon
|
||||
:type="field.type"
|
||||
class="mr-2"
|
||||
/>
|
||||
<p class="text-sm text-gray-700 truncate">
|
||||
{{ field.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex border-t pt-2 -mx-2 px-2 justify-end space-x-2">
|
||||
<UButton
|
||||
size="sm"
|
||||
color="primary"
|
||||
class="px-6"
|
||||
:disabled="!selectedField"
|
||||
@click="insertMention"
|
||||
>
|
||||
Insert
|
||||
</UButton>
|
||||
<UButton
|
||||
size="sm"
|
||||
color="gray"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, toRefs } from 'vue'
|
||||
import BlockTypeIcon from '~/components/open/forms/components/BlockTypeIcon.vue'
|
||||
import blocksTypes from '~/data/blocks_types.json'
|
||||
const props = defineProps({
|
||||
state: Object,
|
||||
mentions: Array
|
||||
})
|
||||
defineShortcuts({
|
||||
escape: () => {
|
||||
open.value = false
|
||||
}
|
||||
})
|
||||
const { open, onInsert, onCancel } = toRefs(props.state)
|
||||
const selectedField = ref(null)
|
||||
const fallbackValue = ref('')
|
||||
const filteredMentions = computed(() => {
|
||||
return props.mentions.filter(mention => blocksTypes[mention.type]?.is_input ?? false)
|
||||
})
|
||||
function selectField(field, insert = false) {
|
||||
selectedField.value = {...field}
|
||||
if (insert) {
|
||||
insertMention()
|
||||
}
|
||||
}
|
||||
watch(open, (newValue) => {
|
||||
if (newValue) {
|
||||
selectedField.value = null
|
||||
fallbackValue.value = ''
|
||||
}
|
||||
})
|
||||
const insertMention = () => {
|
||||
if (selectedField.value && onInsert.value) {
|
||||
onInsert.value({
|
||||
field: selectedField.value,
|
||||
fallback: fallbackValue.value
|
||||
})
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
const cancel = () => {
|
||||
if (onCancel.value) {
|
||||
onCancel.value()
|
||||
}
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
98
client/components/forms/components/QuillyEditor.vue
Normal file
98
client/components/forms/components/QuillyEditor.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="quilly-editor"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Quill from 'quill'
|
||||
import 'quill/dist/quill.snow.css'
|
||||
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'text-change',
|
||||
'selection-change',
|
||||
'editor-change',
|
||||
'blur',
|
||||
'focus',
|
||||
'ready'
|
||||
])
|
||||
|
||||
let quillInstance = null
|
||||
const container = ref(null)
|
||||
let isInternalChange = false
|
||||
|
||||
const setContents = (content) => {
|
||||
if (!quillInstance) return
|
||||
|
||||
isInternalChange = true
|
||||
quillInstance.root.innerHTML = content
|
||||
quillInstance.update()
|
||||
isInternalChange = false
|
||||
}
|
||||
|
||||
const initializeQuill = () => {
|
||||
if (container.value) {
|
||||
quillInstance = new Quill(container.value, props.options)
|
||||
|
||||
quillInstance.on('selection-change', (range, oldRange, source) => {
|
||||
if (!range) {
|
||||
emit('blur', quillInstance)
|
||||
} else {
|
||||
emit('focus', quillInstance)
|
||||
}
|
||||
emit('selection-change', { range, oldRange, source })
|
||||
})
|
||||
|
||||
quillInstance.on('text-change', (delta, oldContents, source) => {
|
||||
if (!isInternalChange) {
|
||||
const html = quillInstance.root.innerHTML
|
||||
emit('text-change', { delta, oldContents, source })
|
||||
emit('update:modelValue', html)
|
||||
}
|
||||
})
|
||||
|
||||
quillInstance.on('editor-change', (eventName, ...args) => {
|
||||
emit('editor-change', eventName, ...args)
|
||||
})
|
||||
|
||||
if (props.modelValue) {
|
||||
setContents(props.modelValue)
|
||||
}
|
||||
|
||||
emit('ready', quillInstance)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeQuill()
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (quillInstance && newValue !== quillInstance.root.innerHTML) {
|
||||
setContents(newValue || '')
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (quillInstance) {
|
||||
quillInstance.off('selection-change')
|
||||
quillInstance.off('text-change')
|
||||
quillInstance.off('editor-change')
|
||||
}
|
||||
quillInstance = null
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user