diff --git a/api/app/Open/MentionParser.php b/api/app/Open/MentionParser.php index b679e8e3..5e254bda 100644 --- a/api/app/Open/MentionParser.php +++ b/api/app/Open/MentionParser.php @@ -4,6 +4,7 @@ namespace App\Open; use DOMDocument; use DOMXPath; +use DOMElement; class MentionParser { @@ -39,21 +40,24 @@ 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) { - $fieldId = $element->getAttribute('mention-field-id'); - $fallback = $element->getAttribute('mention-fallback'); - $value = $this->getData($fieldId); + if ($element instanceof DOMElement) { + $fieldId = $element->getAttribute('mention-field-id'); + $fallback = $element->getAttribute('mention-fallback'); + $value = $this->getData($fieldId); - if ($value !== null) { - $textNode = $doc->createTextNode(is_array($value) ? implode($this->urlFriendly ? ',+' : ', ', $value) : $value); - $element->parentNode->replaceChild($textNode, $element); - } elseif ($fallback) { - $textNode = $doc->createTextNode($fallback); - $element->parentNode->replaceChild($textNode, $element); - } else { - $element->parentNode->removeChild($element); + if ($value !== null) { + $textNode = $doc->createTextNode(is_array($value) ? implode($this->urlFriendly ? ',+' : ', ', $value) : $value); + $element->parentNode->replaceChild($textNode, $element); + } elseif ($fallback) { + $textNode = $doc->createTextNode($fallback); + $element->parentNode->replaceChild($textNode, $element); + } else { + $element->parentNode->removeChild($element); + } } } diff --git a/api/tests/Unit/Service/Forms/MentionParserTest.php b/api/tests/Unit/Service/Forms/MentionParserTest.php index 0607e17b..61bdbb58 100644 --- a/api/tests/Unit/Service/Forms/MentionParserTest.php +++ b/api/tests/Unit/Service/Forms/MentionParserTest.php @@ -35,6 +35,18 @@ describe('MentionParser', function () { expect($result)->toBe('
Hello !
'); }); + it('supports mention="true" syntax', function () { + $content = '

Hello Full Name

'; + $data = [ + ['id' => '123', 'value' => 'John Doe'] + ]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Hello John Doe

'); + }); + describe('parseAsText', function () { it('converts HTML to plain text with proper line breaks', function () { $content = '
First line
Second line
'; @@ -144,6 +156,29 @@ describe('MentionParser', function () { expect($result)->toBe('

Tags: PHP, Laravel, Testing

'); }); + test('it supports mention="true" attributes', function () { + $content = '

Hello Full Name

'; + $data = [['id' => '123', 'value' => 'John Doe']]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Hello John Doe

'); + }); + + test('it handles multiple mentions with mention="true" syntax', function () { + $content = '

Name and Title

'; + $data = [ + ['id' => '123', 'value' => 'John Doe'], + ['id' => '456', 'value' => 'Developer'], + ]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

John Doe and Developer

'); + }); + test('it preserves HTML structure', function () { $content = '

Hello Placeholder

How are you?

'; $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 = '

Hello Full Name

'; + $data = [['id' => '%3ARGE', 'value' => 'John Doe']]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Hello John Doe

'); + }); + + test('it handles URL-encoded field IDs with mention="true" syntax', function () { + $content = '

Hello Full Name

'; + $data = [['id' => '%3ARGE', 'value' => 'John Doe']]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Hello John Doe

'); + }); + + test('it handles multiple mentions with URL-encoded IDs and mention="true" syntax', function () { + $content = '

Full Name and Phone

'; + $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('

John Doe and 123-456-7890

'); + }); + + test('it recreates real-world example with URL-encoded IDs', function () { + $content = '

Hello there 👋

This is a confirmation that your submission was successfully saved.

Full NameContact FormPhone Number

'; + $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('

Hello there 👋

This is a confirmation that your submission was successfully saved.

jujujujujujujuuj555-1234

'); + }); + describe('urlFriendlyOutput', function () { test('it encodes special characters in values', function () { $content = '

Test: Placeholder

'; diff --git a/client/components/forms/MentionInput.vue b/client/components/forms/MentionInput.vue index 287b4708..f14ad612 100644 --- a/client/components/forms/MentionInput.vue +++ b/client/components/forms/MentionInput.vue @@ -3,12 +3,12 @@ - + - +
- + - + - - - - \ No newline at end of file +}) + +defineExpose({ + editableDiv, + compVal, + mentionState, + openMentionDropdown, + onInput, +}) + + + diff --git a/client/components/forms/RichTextAreaInput.client.vue b/client/components/forms/RichTextAreaInput.client.vue index 57aad7ae..be16f401 100644 --- a/client/components/forms/RichTextAreaInput.client.vue +++ b/client/components/forms/RichTextAreaInput.client.vue @@ -31,6 +31,7 @@ :options="quillOptions" :disabled="disabled" :style="inputStyle" + @ready="onEditorReady" />
@@ -59,7 +60,7 @@ @@ -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 || '', diff --git a/client/components/forms/components/MentionDropdown.vue b/client/components/forms/components/MentionDropdown.vue index 7ce580b5..8909c4e6 100644 --- a/client/components/forms/components/MentionDropdown.vue +++ b/client/components/forms/components/MentionDropdown.vue @@ -1,7 +1,7 @@ - - \ No newline at end of file +} + +watch(() => props.mentionState.open, (newValue) => { + if (newValue) { + selectedField.value = null + fallbackValue.value = '' + } +}) + +const insertMention = () => { + if (selectedField.value && props.mentionState.onInsert) { + props.mentionState.onInsert({ + field: selectedField.value, + fallback: fallbackValue.value + }) + } +} + +const cancel = () => { + if (props.mentionState.onCancel) { + props.mentionState.onCancel() + } +} + \ No newline at end of file diff --git a/client/components/forms/components/QuillyEditor.vue b/client/components/forms/components/QuillyEditor.vue index 3d0e0b40..94978277 100644 --- a/client/components/forms/components/QuillyEditor.vue +++ b/client/components/forms/components/QuillyEditor.vue @@ -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) { diff --git a/client/components/global/Settings/SettingsSection.vue b/client/components/global/Settings/SettingsSection.vue index 47eb4d65..990d44d3 100644 --- a/client/components/global/Settings/SettingsSection.vue +++ b/client/components/global/Settings/SettingsSection.vue @@ -1,6 +1,6 @@ - diff --git a/client/lib/quill/quillMentionExtension.js b/client/lib/quill/quillMentionExtension.js index 875b207d..ba683c11 100644 --- a/client/lib/quill/quillMentionExtension.js +++ b/client/lib/quill/quillMentionExtension.js @@ -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 + }) } \ No newline at end of file