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:
335
client/lib/quill/quillMentionExtension.js
vendored
335
client/lib/quill/quillMentionExtension.js
vendored
@@ -1,177 +1,198 @@
|
||||
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
|
||||
}
|
||||
// Core imports
|
||||
const ParchmentEmbed = Quill.import('parchment').EmbedBlot
|
||||
const Delta = Quill.import('delta')
|
||||
const Parchment = Quill.import('parchment')
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
// Match any span with a 'mention' attribute
|
||||
static matches(domNode) {
|
||||
return (
|
||||
domNode instanceof HTMLElement &&
|
||||
domNode.tagName === this.tagName &&
|
||||
domNode.hasAttribute('mention')
|
||||
)
|
||||
}
|
||||
|
||||
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', fieldName)
|
||||
node.setAttribute('mention-fallback', fallbackText)
|
||||
|
||||
const textNode = document.createTextNode(displayText)
|
||||
node.appendChild(textNode)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
static value(domNode) {
|
||||
return {
|
||||
field: {
|
||||
id: domNode.getAttribute('mention-field-id') || '',
|
||||
name: domNode.getAttribute('mention-field-name') || ''
|
||||
},
|
||||
fallback: domNode.getAttribute('mention-fallback') || ''
|
||||
}
|
||||
newDelta.push(op)
|
||||
return newDelta
|
||||
}, [])
|
||||
return processedDelta
|
||||
}
|
||||
}
|
||||
Quill.register('modules/clipboard', MentionClipboard, true)
|
||||
|
||||
class MentionBlot extends Inline {
|
||||
static blotName = 'mention'
|
||||
static tagName = 'SPAN'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
static setAttributes(node, data) {
|
||||
// Only set attributes if we have valid data
|
||||
if (!data || !data.field || !data.field.id) {
|
||||
return
|
||||
static formats(domNode) {
|
||||
return MentionBlot.value(domNode)
|
||||
}
|
||||
node.setAttribute('contenteditable', 'false')
|
||||
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 || ''
|
||||
}
|
||||
|
||||
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') || ''
|
||||
length() {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
static value(domNode) {
|
||||
return {
|
||||
field: {
|
||||
id: domNode.getAttribute('mention-field-id') || '',
|
||||
name: domNode.getAttribute('mention-field-name') || ''
|
||||
},
|
||||
fallback: domNode.getAttribute('mention-fallback') || ''
|
||||
}
|
||||
}
|
||||
|
||||
// Override attach to ensure contenteditable is always set
|
||||
attach() {
|
||||
super.attach()
|
||||
this.domNode.setAttribute('contenteditable', 'false')
|
||||
}
|
||||
|
||||
length() {
|
||||
return 1
|
||||
}
|
||||
// Register the blot with Quill
|
||||
QuillInstance.register('formats/mention', MentionBlot)
|
||||
}
|
||||
|
||||
Quill.register(MentionBlot)
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
const mentionState = reactive({
|
||||
open: false,
|
||||
onInsert: null,
|
||||
onCancel: null,
|
||||
})
|
||||
|
||||
class MentionModule {
|
||||
constructor(quill, options) {
|
||||
this.quill = quill
|
||||
this.options = options
|
||||
|
||||
this.setupMentions()
|
||||
}
|
||||
|
||||
setupMentions() {
|
||||
const toolbar = this.quill.getModule('toolbar')
|
||||
if (toolbar) {
|
||||
toolbar.addHandler('mention', () => {
|
||||
const range = this.quill.getSelection()
|
||||
if (range) {
|
||||
mentionState.open = true
|
||||
mentionState.onInsert = (mention) => {
|
||||
this.insertMention(mention, range.index)
|
||||
/**
|
||||
* MentionModule - Handles mention UI integration with Quill
|
||||
*/
|
||||
if (!QuillInstance.imports['modules/mention']) {
|
||||
class MentionModule {
|
||||
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()
|
||||
}
|
||||
|
||||
setupMentions() {
|
||||
const toolbar = this.quill.getModule('toolbar')
|
||||
if (toolbar) {
|
||||
toolbar.addHandler('mention', () => {
|
||||
const range = this.quill.getSelection()
|
||||
if (range) {
|
||||
this.state.open = true
|
||||
this.state.onInsert = (mentionData) => this.insertMention(mentionData, range.index)
|
||||
this.state.onCancel = () => {
|
||||
this.state.open = false
|
||||
}
|
||||
}
|
||||
mentionState.onCancel = () => {
|
||||
mentionState.open = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
insertMention(mentionData, index) {
|
||||
if (!mentionData || typeof mentionData.field !== 'object' || mentionData.field === null) {
|
||||
console.error("Invalid mention data for insertion:", mentionData)
|
||||
return
|
||||
}
|
||||
|
||||
this.state.open = false
|
||||
|
||||
// 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(() => {
|
||||
this.quill.focus()
|
||||
this.quill.setSelection(index + 1, 0, QuillInstance.sources.SILENT)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
insertMention(mention, index) {
|
||||
mentionState.open = false
|
||||
|
||||
// Insert the mention
|
||||
this.quill.insertEmbed(index, 'mention', mention, Quill.sources.USER)
|
||||
|
||||
// Calculate the length of the inserted mention
|
||||
const mentionLength = this.quill.getLength() - index
|
||||
|
||||
nextTick(() => {
|
||||
// Focus the editor
|
||||
this.quill.focus()
|
||||
|
||||
// Set the selection after the mention
|
||||
this.quill.setSelection(index + mentionLength, 0, Quill.sources.SILENT)
|
||||
})
|
||||
|
||||
// Register the module
|
||||
QuillInstance.register('modules/mention', MentionModule)
|
||||
}
|
||||
|
||||
// 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(/ |\u00A0/g, ' ')
|
||||
}
|
||||
Quill.prototype.getSemanticHTML.isPatched = true
|
||||
}
|
||||
}
|
||||
|
||||
Quill.register('modules/mention', MentionModule)
|
||||
|
||||
return mentionState
|
||||
|
||||
// Return reactive state for component binding
|
||||
return reactive({
|
||||
open: false,
|
||||
onInsert: null,
|
||||
onCancel: null
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user