Fix quill and mentions (#758)

* Enhance MentionParser and Related Components for Improved Mention Handling

- Updated `MentionParser.php` to support the `mention="true"` syntax, allowing for more flexible mention parsing.
- Added tests in `MentionParserTest.php` to verify the handling of mentions with the `mention="true"` attribute, including support for URL-encoded field IDs.
- Refactored `MentionInput.vue`, `MentionDropdown.vue`, and `RichTextAreaInput.client.vue` to ensure consistent use of `mention-state` and improve mention dropdown functionality.
- Enhanced `quillMentionExtension.js` to better manage mention data and improve integration with Quill editor.

These changes aim to improve the functionality and reliability of the mention feature across the application, ensuring a better user experience.

* Refactor FormInformation Component for Improved Logic and Structure

- Updated `FormInformation.vue` to utilize the composition API with script setup syntax, enhancing readability and maintainability.
- Replaced `v-if` condition for form visibility with a computed property `isFormClosingOrClosed` for better clarity.
- Streamlined data handling by converting data and computed properties to reactive state and computed properties, respectively.
- Improved the copy settings logic to utilize refs, ensuring proper state management.

These changes aim to enhance the overall structure and functionality of the `FormInformation` component, providing a better user experience and code clarity.
This commit is contained in:
Chirag Chhatrala 2025-05-16 20:39:07 +05:30 committed by GitHub
parent 29b513a6f6
commit b5517c6fce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 633 additions and 469 deletions

View File

@ -4,6 +4,7 @@ namespace App\Open;
use DOMDocument;
use DOMXPath;
use DOMElement;
class MentionParser
{
@ -39,9 +40,11 @@ class MentionParser
libxml_use_internal_errors($internalErrors);
$xpath = new DOMXPath($doc);
$mentionElements = $xpath->query("//span[@mention]");
$mentionElements = $xpath->query("//span[@mention or @mention='true']");
foreach ($mentionElements as $element) {
if ($element instanceof DOMElement) {
$fieldId = $element->getAttribute('mention-field-id');
$fallback = $element->getAttribute('mention-fallback');
$value = $this->getData($fieldId);
@ -56,6 +59,7 @@ class MentionParser
$element->parentNode->removeChild($element);
}
}
}
// Extract and return the processed HTML content
$result = $doc->saveHTML($doc->getElementsByTagName('root')->item(0));

View File

@ -35,6 +35,18 @@ describe('MentionParser', function () {
expect($result)->toBe('<div>Hello !</div>');
});
it('supports mention="true" syntax', function () {
$content = '<p>Hello <span mention="true" mention-field-id="123" mention-fallback="">Full Name</span></p>';
$data = [
['id' => '123', 'value' => 'John Doe']
];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<p>Hello John Doe</p>');
});
describe('parseAsText', function () {
it('converts HTML to plain text with proper line breaks', function () {
$content = '<div>First line</div><div>Second line</div>';
@ -144,6 +156,29 @@ describe('MentionParser', function () {
expect($result)->toBe('<p>Tags: PHP, Laravel, Testing</p>');
});
test('it supports mention="true" attributes', function () {
$content = '<p>Hello <span contenteditable="false" mention="true" mention-field-id="123" mention-field-name="Full Name" mention-fallback="">Full Name</span></p>';
$data = [['id' => '123', 'value' => 'John Doe']];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<p>Hello John Doe</p>');
});
test('it handles multiple mentions with mention="true" syntax', function () {
$content = '<p><span contenteditable="false" mention="true" mention-field-id="123" mention-field-name="Name" mention-fallback="">Name</span> and <span contenteditable="false" mention="true" mention-field-id="456" mention-field-name="Title" mention-fallback="">Title</span></p>';
$data = [
['id' => '123', 'value' => 'John Doe'],
['id' => '456', 'value' => 'Developer'],
];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<p>John Doe and Developer</p>');
});
test('it preserves HTML structure', function () {
$content = '<div><p>Hello <span mention mention-field-id="123">Placeholder</span></p><p>How are you?</p></div>';
$data = [['id' => '123', 'value' => 'World']];
@ -174,6 +209,53 @@ describe('MentionParser', function () {
expect($result)->toBe('some text replaced text dewde');
});
test('it handles URL-encoded field IDs', function () {
$content = '<p>Hello <span mention mention-field-id="%3ARGE" mention-fallback="">Full Name</span></p>';
$data = [['id' => '%3ARGE', 'value' => 'John Doe']];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<p>Hello John Doe</p>');
});
test('it handles URL-encoded field IDs with mention="true" syntax', function () {
$content = '<p>Hello <span contenteditable="false" mention="true" mention-field-id="%3ARGE" mention-field-name="Full Name" mention-fallback="">Full Name</span></p>';
$data = [['id' => '%3ARGE', 'value' => 'John Doe']];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<p>Hello John Doe</p>');
});
test('it handles multiple mentions with URL-encoded IDs and mention="true" syntax', function () {
$content = '<p><span contenteditable="false" mention="true" mention-field-id="%3ARGE" mention-field-name="Full Name" mention-fallback="">Full Name</span> and <span contenteditable="false" mention="true" mention-field-id="V%7D%40S" mention-field-name="Phone Number" mention-fallback="">Phone</span></p>';
$data = [
['id' => '%3ARGE', 'value' => 'John Doe'],
['id' => 'V%7D%40S', 'value' => '123-456-7890'],
];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<p>John Doe and 123-456-7890</p>');
});
test('it recreates real-world example with URL-encoded IDs', function () {
$content = '<p>Hello there 👋 </p><p>This is a confirmation that your submission was successfully saved.</p><p><span contenteditable="false" mention="true" mention-field-id="%3ARGE" mention-field-name="Full Name" mention-fallback="">Full Name</span><span contenteditable="false" mention="true" mention-field-id="title" mention-field-name="Contact Form" mention-fallback="">Contact Form</span><span contenteditable="false" mention="true" mention-field-id="V%7D%40S" mention-field-name="Phone Number" mention-fallback="">Phone Number</span></p>';
$data = [
['id' => '%3ARGE', 'value' => 'jujujujuju'],
['id' => 'title', 'value' => 'jujuuj'],
['id' => 'V%7D%40S', 'value' => '555-1234'],
];
$parser = new MentionParser($content, $data);
$result = $parser->parse();
expect($result)->toBe('<p>Hello there 👋 </p><p>This is a confirmation that your submission was successfully saved.</p><p>jujujujujujujuuj555-1234</p>');
});
describe('urlFriendlyOutput', function () {
test('it encodes special characters in values', function () {
$content = '<p>Test: <span mention mention-field-id="123">Placeholder</span></p>';

View File

@ -5,7 +5,7 @@
</template>
<MentionDropdown
:state="mentionState"
:mention-state="mentionState"
:mentions="mentions"
/>
@ -55,62 +55,76 @@
</InputWrapper>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
import MentionDropdown from './components/MentionDropdown.vue'
const props = defineProps({
<script setup>
import { ref, onMounted, watch, reactive } 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, hasError, inputWrapperProps } = useFormInput(props, { emit })
const editableDiv = ref(null)
const savedRange = ref(null)
const subscriptionModalStore = useSubscriptionModalStore()
})
const mentionState = ref({
const emit = defineEmits(['update:modelValue'])
const { compVal, inputStyle, hasError, inputWrapperProps } = useFormInput(props, { emit })
const editableDiv = ref(null)
const savedRange = ref(null)
const subscriptionModalStore = useSubscriptionModalStore()
// Create a reactive state object for the mention dropdown
const mentionState = reactive({
open: false,
onInsert: (mention) => {
insertMention(mention)
mentionState.value.open = false
mentionState.open = false
},
onCancel: () => {
mentionState.value.open = false
mentionState.open = false
restoreSelection()
},
})
const createMentionSpan = (mention) => {
})
const createMentionSpan = (mention) => {
const mentionSpan = document.createElement('span')
mentionSpan.setAttribute('mention', 'true')
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.setAttribute('class', 'mention-item')
mentionSpan.textContent = mention.field.name.length > 25 ? `${mention.field.name.slice(0, 25)}...` : mention.field.name
return mentionSpan
}
const insertMention = (mention) => {
}
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 = () => {
}
const openMentionDropdown = () => {
if (props.disableMention) {
subscriptionModalStore.setModalContent('Upgrade to Pro', 'Upgrade to Pro to use mentions')
subscriptionModalStore.openModal()
@ -128,58 +142,67 @@
selection.addRange(range)
savedRange.value = range
}
mentionState.value.open = true
}
const saveSelection = () => {
mentionState.open = true
}
const saveSelection = () => {
const selection = window.getSelection()
if (selection.rangeCount > 0) {
savedRange.value = selection.getRangeAt(0)
}
}
const restoreSelection = () => {
}
const restoreSelection = () => {
if (savedRange.value) {
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(savedRange.value)
editableDiv.value.focus()
}
}
const updateCompVal = () => {
}
const updateCompVal = () => {
compVal.value = editableDiv.value.innerHTML
}
const onInput = () => {
}
const onInput = () => {
updateCompVal()
}
onMounted(() => {
}
onMounted(() => {
if (compVal.value) {
editableDiv.value.innerHTML = compVal.value
}
})
watch(compVal, (newVal) => {
})
watch(compVal, (newVal) => {
if (editableDiv.value && editableDiv.value.innerHTML !== newVal) {
editableDiv.value.innerHTML = newVal
}
})
defineExpose({
})
defineExpose({
editableDiv,
compVal,
mentionState,
openMentionDropdown,
onInput,
})
</script>
})
</script>
<style scoped>
.mention-input {
<style scoped>
.mention-input {
min-height: 1.5rem;
white-space: pre-wrap;
word-break: break-word;
}
.mention-input:empty::before {
}
.mention-input:empty::before {
content: attr(placeholder);
color: #9ca3af;
}
.mention-input span[mention] {
}
.mention-input span[mention] {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
@ -195,5 +218,5 @@
line-height: 1.25rem;
position: relative;
vertical-align: baseline;
}
</style>
}
</style>

View File

@ -31,6 +31,7 @@
:options="quillOptions"
:disabled="disabled"
:style="inputStyle"
@ready="onEditorReady"
/>
</div>
@ -59,7 +60,7 @@
<MentionDropdown
v-if="enableMentions && mentionState"
:state="mentionState"
:mention-state="mentionState"
:mentions="mentions"
/>
</InputWrapper>
@ -103,12 +104,23 @@ watch(compVal, (val) => {
}
}, { immediate: true })
// Move the mention extension registration to onMounted
if (props.enableMentions && !Quill.imports['blots/mention']) {
// Initialize mention extension
if (props.enableMentions) {
// Register the mention extension with Quill
mentionState.value = registerMentionExtension(Quill)
}
// Handle editor ready event
const onEditorReady = (quillInstance) => {
// If we have a mention module, get its state
if (props.enableMentions && quillInstance) {
const mentionModule = quillInstance.getModule('mention')
if (mentionModule && mentionModule.state) {
mentionState.value = mentionModule.state
}
}
}
const quillOptions = computed(() => {
const defaultOptions = {
placeholder: props.placeholder || '',

View File

@ -1,7 +1,7 @@
<template>
<UPopover
ref="popover"
v-model:open="open"
v-model:open="mentionState.open"
class="h-0"
@close="cancel"
>
@ -67,50 +67,61 @@
</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
<script setup>
import { ref, computed, watch } from 'vue'
import BlockTypeIcon from '~/components/open/forms/components/BlockTypeIcon.vue'
import blocksTypes from '~/data/blocks_types.json'
const props = defineProps({
mentionState: {
type: Object,
required: true
},
mentions: {
type: Array,
required: true
}
})
const { open, onInsert, onCancel } = toRefs(props.state)
const selectedField = ref(null)
const fallbackValue = ref('')
const filteredMentions = computed(() => {
})
defineShortcuts({
escape: () => {
props.mentionState.open = false
}
})
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) {
})
function selectField(field, insert = false) {
selectedField.value = {...field}
if (insert) {
insertMention()
}
}
watch(open, (newValue) => {
}
watch(() => props.mentionState.open, (newValue) => {
if (newValue) {
selectedField.value = null
fallbackValue.value = ''
}
})
const insertMention = () => {
if (selectedField.value && onInsert.value) {
onInsert.value({
})
const insertMention = () => {
if (selectedField.value && props.mentionState.onInsert) {
props.mentionState.onInsert({
field: selectedField.value,
fallback: fallbackValue.value
})
open.value = false
}
}
const cancel = () => {
if (props.mentionState.onCancel) {
props.mentionState.onCancel()
}
const cancel = () => {
if (onCancel.value) {
onCancel.value()
}
open.value = false
}
</script>
}
</script>

View File

@ -35,13 +35,40 @@ let quillInstance = null
const container = ref(null)
const model = ref(props.modelValue)
// Safely paste HTML content with handling for empty content
const pasteHTML = (instance) => {
instance.clipboard.dangerouslyPasteHTML(props.modelValue || '', 'silent')
if (!props.modelValue) {
instance.setContents([])
return
}
try {
instance.clipboard.dangerouslyPasteHTML(props.modelValue, 'silent')
} catch (error) {
console.error('Error pasting HTML:', error)
// Fallback to setting empty content
instance.setContents([])
}
}
const initializeQuill = () => {
if (container.value) {
quillInstance = new Quill(container.value, props.options)
// Merge default options with user options
const defaultOptions = {
formats: ['bold', 'color', 'font', 'code', 'italic', 'link', 'size', 'strike', 'script', 'underline', 'header', 'list', 'mention']
}
const mergedOptions = {
...defaultOptions,
...props.options,
modules: {
...defaultOptions.modules,
...(props.options.modules || {})
}
}
// Initialize Quill with merged options
quillInstance = new Quill(container.value, mergedOptions)
quillInstance.on('selection-change', (range, oldRange, source) => {
if (!range) {

View File

@ -1,6 +1,6 @@
<template>
<div
v-show="isActive"
v-if="isActive"
class="settings-section"
>
<h3 class="text-xl font-medium mb-1">

View File

@ -343,7 +343,7 @@ const triggerSubmit = async () => {
submissionId: submissionId.value
}).then(result => {
if (result) {
submittedData.value = result || {}
submittedData.value = formManager.form.data()
if (result?.submission_id) {
submissionId.value = result.submission_id

View File

@ -35,7 +35,7 @@
:options="visibilityOptions"
/>
<div
v-if="form.closes_at || form.visibility == 'closed'"
v-if="isFormClosingOrClosed"
class="bg-gray-50 border rounded-lg px-4 py-2"
>
<rich-text-area-input
@ -112,86 +112,72 @@
</modal>
</template>
<script>
import clonedeep from "clone-deep"
import { default as _has } from "lodash/has"
<script setup>
import clonedeep from 'clone-deep'
import { default as _has } from 'lodash/has'
export default {
setup() {
const formsStore = useFormsStore()
const workingFormStore = useWorkingFormStore()
const { getAll: forms } = storeToRefs(formsStore)
return {
forms,
formsStore,
workingFormStore,
}
},
// Store setup
const formsStore = useFormsStore()
const workingFormStore = useWorkingFormStore()
const { content: form } = storeToRefs(workingFormStore)
const forms = computed(() => formsStore.getAll)
data() {
return {
showCopyFormSettingsModal: false,
copyFormId: null,
visibilityOptions: [
// Reactive state
const showCopyFormSettingsModal = ref(false)
const copyFormId = ref(null)
// Computed properties
const visibilityOptions = [
{
name: "Published",
value: "public",
name: 'Published',
value: 'public',
},
{
name: "Draft - not publicly accessible",
value: "draft",
name: 'Draft - not publicly accessible',
value: 'draft',
},
{
name: "Closed - won't accept new submissions",
value: "closed",
},
],
}
name: 'Closed - won\'t accept new submissions',
value: 'closed',
},
]
computed: {
copyFormOptions() {
return this.forms
.filter((form) => {
return this.form.id !== form.id
const copyFormOptions = computed(() => {
return forms.value
.filter((formItem) => {
return form.value.id !== formItem.id
})
.map((form) => {
.map((formItem) => {
return {
name: form.title,
value: form.id,
name: formItem.title,
value: formItem.id,
}
})
},
form: {
get() {
return this.workingFormStore.content
},
/* We add a setter */
set(value) {
this.workingFormStore.set(value)
},
},
allTagsOptions() {
return this.formsStore.allTags.map((tagname) => {
})
const allTagsOptions = computed(() => {
return formsStore.allTags.map((tagname) => {
return {
name: tagname,
value: tagname,
}
})
},
},
})
watch: {},
// New computed property for v-if condition
const isFormClosingOrClosed = computed(() => {
return form.value.closes_at || form.value.visibility === 'closed'
})
mounted() {},
methods: {
copySettings() {
if (this.copyFormId == null) return
// Methods
const copySettings = () => {
if (copyFormId.value == null)
return
const copyForm = clonedeep(
this.forms.find((form) => form.id === this.copyFormId),
forms.value.find(form => form.id === copyFormId.value),
)
if (!copyForm) return;
if (!copyForm)
return;
// Clean copy from form
[
@ -214,17 +200,15 @@ export default {
"deleted_at",
"last_edited_human",
].forEach((property) => {
if (_has(copyForm, property)) {
if (_has(copyForm, property))
delete copyForm[property]
}
})
// Apply changes
Object.keys(copyForm).forEach((property) => {
this.form[property] = copyForm[property]
form.value[property] = copyForm[property]
})
this.showCopyFormSettingsModal = false
},
},
showCopyFormSettingsModal.value = false
useAlert().success('Form settings copied.')
}
</script>

View File

@ -1,100 +1,58 @@
import { reactive } from 'vue'
import { reactive, nextTick } from 'vue'
import Quill from 'quill'
const Inline = Quill.import('blots/inline')
const Clipboard = Quill.import('modules/clipboard')
export default function registerMentionExtension(Quill) {
// Extend Clipboard to handle pasted content
class MentionClipboard extends Clipboard {
convert(html) {
const delta = super.convert(html)
const processedDelta = delta.ops.reduce((newDelta, op) => {
if (op.attributes && op.attributes.mention) {
const mentionData = op.attributes.mention
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
}
}
newDelta.push(op)
return newDelta
}, [])
return processedDelta
}
}
Quill.register('modules/clipboard', MentionClipboard, true)
// Core imports
const ParchmentEmbed = Quill.import('parchment').EmbedBlot
const Delta = Quill.import('delta')
const Parchment = Quill.import('parchment')
class MentionBlot extends Inline {
/**
* Utility to remove BOM and other zero-width characters from a string.
*/
function cleanString(str) {
if (typeof str !== 'string') return ''
return str.replace(/\uFEFF/g, '').replace(/\s+/g, ' ').trim()
}
export default function registerMentionExtension(QuillInstance) {
/**
* MentionBlot - Embeds a mention as a non-editable element
*/
if (!QuillInstance.imports['formats/mention']) {
class MentionBlot extends ParchmentEmbed {
static blotName = 'mention'
static tagName = 'SPAN'
static scope = Parchment.Scope.INLINE
static create(data) {
// Only create mention if we have valid data
if (!data || !data.field || !data.field.id) {
return null
}
let node = super.create()
MentionBlot.setAttributes(node, data)
return node
// Match any span with a 'mention' attribute
static matches(domNode) {
return (
domNode instanceof HTMLElement &&
domNode.tagName === this.tagName &&
domNode.hasAttribute('mention')
)
}
static setAttributes(node, data) {
// Only set attributes if we have valid data
if (!data || !data.field || !data.field.id) {
return
}
static create(value) {
const node = document.createElement(this.tagName)
node.setAttribute('contenteditable', 'false')
const data = (typeof value === 'object' && value !== null) ? value : { field: {}, fallback: '' }
data.field = (typeof data.field === 'object' && data.field !== null) ? data.field : {}
const fieldName = cleanString(data.field.name)
const fallbackText = cleanString(data.fallback)
const displayText = fieldName || fallbackText || 'mention'
node.setAttribute('mention', 'true')
node.setAttribute('mention-field-id', data.field.id || '')
node.setAttribute('mention-field-name', data.field.name || '')
node.setAttribute('mention-fallback', data.fallback || '')
node.textContent = data.field.name || ''
}
node.setAttribute('mention-field-name', fieldName)
node.setAttribute('mention-fallback', fallbackText)
static formats(domNode) {
return {
'mention-field-id': domNode.getAttribute('mention-field-id') || '',
'mention-field-name': domNode.getAttribute('mention-field-name') || '',
'mention-fallback': domNode.getAttribute('mention-fallback') || ''
}
}
const textNode = document.createTextNode(displayText)
node.appendChild(textNode)
format(name, value) {
if (name === 'mention' && value) {
MentionBlot.setAttributes(this.domNode, value)
} else {
super.format(name, value)
}
}
formats() {
let formats = super.formats()
formats['mention'] = MentionBlot.value(this.domNode)
return formats
return node
}
static value(domNode) {
@ -107,10 +65,8 @@ export default function registerMentionExtension(Quill) {
}
}
// Override attach to ensure contenteditable is always set
attach() {
super.attach()
this.domNode.setAttribute('contenteditable', 'false')
static formats(domNode) {
return MentionBlot.value(domNode)
}
length() {
@ -118,19 +74,45 @@ export default function registerMentionExtension(Quill) {
}
}
Quill.register(MentionBlot)
// Register the blot with Quill
QuillInstance.register('formats/mention', MentionBlot)
}
const mentionState = reactive({
open: false,
onInsert: null,
onCancel: null,
// Add clipboard matcher for handling mentions in pasted/loaded HTML
if (QuillInstance.clipboard && typeof QuillInstance.clipboard.addMatcher === 'function') {
QuillInstance.clipboard.addMatcher('span[mention]', (node, delta) => {
if (node.hasAttribute('mention')) {
const mentionData = {
field: {
id: node.getAttribute('mention-field-id') || '',
name: node.getAttribute('mention-field-name') || ''
},
fallback: node.getAttribute('mention-fallback') || ''
}
return new Delta().insert({ mention: mentionData })
}
return delta
})
}
/**
* MentionModule - Handles mention UI integration with Quill
*/
if (!QuillInstance.imports['modules/mention']) {
class MentionModule {
constructor(quill, options) {
constructor(quill, options = {}) {
this.quill = quill
this.options = options
// Reactive state for the UI component
this.state = reactive({
open: false,
onInsert: null,
onCancel: null
})
this.setupMentions()
}
@ -140,38 +122,77 @@ export default function registerMentionExtension(Quill) {
toolbar.addHandler('mention', () => {
const range = this.quill.getSelection()
if (range) {
mentionState.open = true
mentionState.onInsert = (mention) => {
this.insertMention(mention, range.index)
}
mentionState.onCancel = () => {
mentionState.open = false
this.state.open = true
this.state.onInsert = (mentionData) => this.insertMention(mentionData, range.index)
this.state.onCancel = () => {
this.state.open = false
}
}
})
}
}
insertMention(mention, index) {
mentionState.open = false
insertMention(mentionData, index) {
if (!mentionData || typeof mentionData.field !== 'object' || mentionData.field === null) {
console.error("Invalid mention data for insertion:", mentionData)
return
}
// Insert the mention
this.quill.insertEmbed(index, 'mention', mention, Quill.sources.USER)
this.state.open = false
// Calculate the length of the inserted mention
const mentionLength = this.quill.getLength() - index
// Handle selection
const selection = this.quill.getSelection()
if (selection && selection.length > 0) {
this.quill.deleteText(selection.index, selection.length, QuillInstance.sources.USER)
index = selection.index
}
// Prepare clean data for the blot
const blotData = {
field: {
id: mentionData.field.id || '',
name: cleanString(mentionData.field.name)
},
fallback: cleanString(mentionData.fallback)
}
// Insert mention as embed using the blotData
this.quill.insertEmbed(index, 'mention', blotData, QuillInstance.sources.USER)
// Move cursor after mention
nextTick(() => {
// Focus the editor
this.quill.focus()
// Set the selection after the mention
this.quill.setSelection(index + mentionLength, 0, Quill.sources.SILENT)
this.quill.setSelection(index + 1, 0, QuillInstance.sources.SILENT)
})
}
}
Quill.register('modules/mention', MentionModule)
// Register the module
QuillInstance.register('modules/mention', MentionModule)
}
return mentionState
// Patch getSemanticHTML to handle non-breaking spaces
if (typeof Quill.prototype.getSemanticHTML === 'function') {
if (!Quill.prototype.getSemanticHTML.isPatched) {
const originalGetSemanticHTML = Quill.prototype.getSemanticHTML
Quill.prototype.getSemanticHTML = function(index = 0, length) {
const currentLength = this.getLength()
const sanitizedIndex = Math.max(0, index)
const sanitizedLength = Math.max(0, Math.min(length ?? (currentLength - sanitizedIndex), currentLength - sanitizedIndex))
if (sanitizedIndex >= currentLength && currentLength > 0) {
return originalGetSemanticHTML.call(this, 0, 0)
}
const html = originalGetSemanticHTML.call(this, sanitizedIndex, sanitizedLength)
return html.replace(/&nbsp;|\u00A0/g, ' ')
}
Quill.prototype.getSemanticHTML.isPatched = true
}
}
// Return reactive state for component binding
return reactive({
open: false,
onInsert: null,
onCancel: null
})
}