Refactor QuillyEditor and Quill Mention Extension for Improved Functionality

- Updated `QuillyEditor.vue` to enhance the handling of model updates and internal changes, ensuring better synchronization between the editor and the model value. Introduced a new `pasteHTML` function to manage HTML content pasting more effectively.
- Refactored `quillMentionExtension.js` to improve the processing of pasted content, ensuring that only valid mentions are retained and transforming flat mention structures into nested ones for better data integrity.
- Enhanced the SCSS styles in `app.scss` to apply list styles to ordered and unordered lists within the `.field-help` class, improving the visual presentation of help text.

These changes aim to improve the overall functionality, maintainability, and user experience of the QuillyEditor component and its associated mention extension.
This commit is contained in:
JhumanJ 2025-05-12 13:08:58 +02:00
parent 0b3011b4ee
commit 1a8af6257a
3 changed files with 140 additions and 96 deletions

View File

@ -5,94 +5,107 @@
/> />
</template> </template>
<script setup> <script setup>
import Quill from 'quill' import Quill from 'quill'
import 'quill/dist/quill.snow.css' import 'quill/dist/quill.snow.css'
import { onMounted, onBeforeUnmount, ref, watch } from 'vue' import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: String, type: String,
default: null default: null
}, },
options: { options: {
type: Object, type: Object,
default: () => ({}) 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) { const emit = defineEmits([
quillInstance = new Quill(container.value, props.options) 'update:modelValue',
'text-change',
quillInstance.on('selection-change', (range, oldRange, source) => { 'selection-change',
if (!range) { 'editor-change',
emit('blur', quillInstance) 'blur',
} else { 'focus',
emit('focus', quillInstance) 'ready'
} ])
emit('selection-change', { range, oldRange, source })
}) let quillInstance = null
const container = ref(null)
quillInstance.on('text-change', (delta, oldContents, source) => { const model = ref(props.modelValue)
if (!isInternalChange) {
const html = quillInstance.root.innerHTML const pasteHTML = (instance) => {
emit('text-change', { delta, oldContents, source }) instance.clipboard.dangerouslyPasteHTML(props.modelValue || '', 'silent')
emit('update:modelValue', html) }
}
}) const initializeQuill = () => {
if (container.value) {
quillInstance.on('editor-change', (eventName, ...args) => { quillInstance = new Quill(container.value, props.options)
emit('editor-change', eventName, ...args)
}) quillInstance.on('selection-change', (range, oldRange, source) => {
if (!range) {
if (props.modelValue) { emit('blur', quillInstance)
setContents(props.modelValue) } else {
emit('focus', quillInstance)
} }
emit('selection-change', { range, oldRange, source })
emit('ready', quillInstance) })
quillInstance.on('text-change', (delta, oldContents, source) => {
// Update local model only on user input
model.value = quillInstance.getSemanticHTML()
emit('text-change', { delta, oldContents, source })
})
quillInstance.on('editor-change', (eventName, ...args) => {
emit('editor-change', eventName, ...args)
})
if (props.modelValue) {
pasteHTML(quillInstance)
model.value = quillInstance.getSemanticHTML()
}
emit('ready', quillInstance)
}
}
onMounted(() => {
initializeQuill()
})
// Watch modelValue and paste HTML if has changes
watch(
() => props.modelValue,
(newValue) => {
if (!quillInstance) return
if (newValue && newValue !== model.value) {
pasteHTML(quillInstance)
model.value = quillInstance.getSemanticHTML()
} else if (!newValue) {
quillInstance.setContents([])
model.value = ''
} }
} }
)
onMounted(() => {
initializeQuill() // Watch model and update modelValue if has changes
}) watch(model, (newValue, oldValue) => {
if (!quillInstance) return
watch(() => props.modelValue, (newValue) => { if (newValue && newValue !== oldValue) {
if (quillInstance && newValue !== quillInstance.root.innerHTML) { emit('update:modelValue', newValue)
setContents(newValue || '') } else if (!newValue) {
} quillInstance.setContents([])
}, { immediate: true }) }
})
onBeforeUnmount(() => {
if (quillInstance) { onBeforeUnmount(() => {
quillInstance.off('selection-change') if (quillInstance) {
quillInstance.off('text-change') quillInstance.off('selection-change')
quillInstance.off('editor-change') quillInstance.off('text-change')
} quillInstance.off('editor-change')
quillInstance = null }
}) quillInstance = null
</script> })
</script>

View File

@ -1,7 +1,6 @@
import { reactive } from 'vue' import { reactive } from 'vue'
import Quill from 'quill' import Quill from 'quill'
const Inline = Quill.import('blots/inline') const Inline = Quill.import('blots/inline')
const Delta = Quill.import('delta')
const Clipboard = Quill.import('modules/clipboard') const Clipboard = Quill.import('modules/clipboard')
export default function registerMentionExtension(Quill) { export default function registerMentionExtension(Quill) {
@ -9,17 +8,42 @@ export default function registerMentionExtension(Quill) {
class MentionClipboard extends Clipboard { class MentionClipboard extends Clipboard {
convert(html) { convert(html) {
const delta = super.convert(html) const delta = super.convert(html)
// Remove any mention formatting from pasted content const processedDelta = delta.ops.reduce((newDelta, op) => {
return delta.reduce((newDelta, op) => {
if (op.attributes && op.attributes.mention) { if (op.attributes && op.attributes.mention) {
// Only keep mentions that have valid field IDs const mentionData = op.attributes.mention
if (!op.attributes.mention['mention-field-id']) { let isValid = false
// Check for nested structure
if (
mentionData &&
typeof mentionData === 'object' &&
mentionData.field &&
typeof mentionData.field === 'object' &&
mentionData.field.id
) {
isValid = true
} else if (
mentionData &&
typeof mentionData === 'object' &&
mentionData['mention-field-id']
) {
// Transform flat structure to nested structure
op.attributes.mention = {
field: {
id: mentionData['mention-field-id'],
name: mentionData['mention-field-name'] || '',
},
fallback: mentionData['mention-fallback'] || '',
}
isValid = true
}
if (!isValid) {
delete op.attributes.mention delete op.attributes.mention
} }
} }
newDelta.push(op) newDelta.push(op)
return newDelta return newDelta
}, new Delta()) }, [])
return processedDelta
} }
} }
Quill.register('modules/clipboard', MentionClipboard, true) Quill.register('modules/clipboard', MentionClipboard, true)
@ -43,7 +67,6 @@ export default function registerMentionExtension(Quill) {
if (!data || !data.field || !data.field.id) { if (!data || !data.field || !data.field.id) {
return return
} }
node.setAttribute('contenteditable', 'false') node.setAttribute('contenteditable', 'false')
node.setAttribute('mention', 'true') node.setAttribute('mention', 'true')
node.setAttribute('mention-field-id', data.field.id || '') node.setAttribute('mention-field-id', data.field.id || '')
@ -70,7 +93,7 @@ export default function registerMentionExtension(Quill) {
formats() { formats() {
let formats = super.formats() let formats = super.formats()
formats['mention'] = MentionBlot.formats(this.domNode) formats['mention'] = MentionBlot.value(this.domNode)
return formats return formats
} }

10
client/scss/app.scss vendored
View File

@ -52,9 +52,17 @@ body.dark * {
} }
.field-help { .field-help {
p { p, ol, ul, li {
@apply text-gray-400 dark:text-gray-500; @apply text-gray-400 dark:text-gray-500;
} }
ol {
@apply list-decimal list-inside;
}
ul {
@apply list-disc list-inside;
}
} }
.public-page { .public-page {