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:
parent
29b513a6f6
commit
b5517c6fce
|
|
@ -4,6 +4,7 @@ namespace App\Open;
|
||||||
|
|
||||||
use DOMDocument;
|
use DOMDocument;
|
||||||
use DOMXPath;
|
use DOMXPath;
|
||||||
|
use DOMElement;
|
||||||
|
|
||||||
class MentionParser
|
class MentionParser
|
||||||
{
|
{
|
||||||
|
|
@ -39,21 +40,24 @@ class MentionParser
|
||||||
libxml_use_internal_errors($internalErrors);
|
libxml_use_internal_errors($internalErrors);
|
||||||
|
|
||||||
$xpath = new DOMXPath($doc);
|
$xpath = new DOMXPath($doc);
|
||||||
$mentionElements = $xpath->query("//span[@mention]");
|
|
||||||
|
$mentionElements = $xpath->query("//span[@mention or @mention='true']");
|
||||||
|
|
||||||
foreach ($mentionElements as $element) {
|
foreach ($mentionElements as $element) {
|
||||||
$fieldId = $element->getAttribute('mention-field-id');
|
if ($element instanceof DOMElement) {
|
||||||
$fallback = $element->getAttribute('mention-fallback');
|
$fieldId = $element->getAttribute('mention-field-id');
|
||||||
$value = $this->getData($fieldId);
|
$fallback = $element->getAttribute('mention-fallback');
|
||||||
|
$value = $this->getData($fieldId);
|
||||||
|
|
||||||
if ($value !== null) {
|
if ($value !== null) {
|
||||||
$textNode = $doc->createTextNode(is_array($value) ? implode($this->urlFriendly ? ',+' : ', ', $value) : $value);
|
$textNode = $doc->createTextNode(is_array($value) ? implode($this->urlFriendly ? ',+' : ', ', $value) : $value);
|
||||||
$element->parentNode->replaceChild($textNode, $element);
|
$element->parentNode->replaceChild($textNode, $element);
|
||||||
} elseif ($fallback) {
|
} elseif ($fallback) {
|
||||||
$textNode = $doc->createTextNode($fallback);
|
$textNode = $doc->createTextNode($fallback);
|
||||||
$element->parentNode->replaceChild($textNode, $element);
|
$element->parentNode->replaceChild($textNode, $element);
|
||||||
} else {
|
} else {
|
||||||
$element->parentNode->removeChild($element);
|
$element->parentNode->removeChild($element);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,18 @@ describe('MentionParser', function () {
|
||||||
expect($result)->toBe('<div>Hello !</div>');
|
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 () {
|
describe('parseAsText', function () {
|
||||||
it('converts HTML to plain text with proper line breaks', function () {
|
it('converts HTML to plain text with proper line breaks', function () {
|
||||||
$content = '<div>First line</div><div>Second line</div>';
|
$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>');
|
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 () {
|
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>';
|
$content = '<div><p>Hello <span mention mention-field-id="123">Placeholder</span></p><p>How are you?</p></div>';
|
||||||
$data = [['id' => '123', 'value' => 'World']];
|
$data = [['id' => '123', 'value' => 'World']];
|
||||||
|
|
@ -174,6 +209,53 @@ describe('MentionParser', function () {
|
||||||
expect($result)->toBe('some text replaced text dewde');
|
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 () {
|
describe('urlFriendlyOutput', function () {
|
||||||
test('it encodes special characters in values', function () {
|
test('it encodes special characters in values', function () {
|
||||||
$content = '<p>Test: <span mention mention-field-id="123">Placeholder</span></p>';
|
$content = '<p>Test: <span mention mention-field-id="123">Placeholder</span></p>';
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@
|
||||||
<template #label>
|
<template #label>
|
||||||
<slot name="label" />
|
<slot name="label" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<MentionDropdown
|
<MentionDropdown
|
||||||
:state="mentionState"
|
:mention-state="mentionState"
|
||||||
:mentions="mentions"
|
:mentions="mentions"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div
|
<div
|
||||||
ref="editableDiv"
|
ref="editableDiv"
|
||||||
|
|
@ -38,14 +38,14 @@
|
||||||
@click="openMentionDropdown"
|
@click="openMentionDropdown"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template
|
<template
|
||||||
v-if="$slots.help"
|
v-if="$slots.help"
|
||||||
#help
|
#help
|
||||||
>
|
>
|
||||||
<slot name="help" />
|
<slot name="help" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template
|
<template
|
||||||
v-if="$slots.error"
|
v-if="$slots.error"
|
||||||
#error
|
#error
|
||||||
|
|
@ -54,146 +54,169 @@
|
||||||
</template>
|
</template>
|
||||||
</InputWrapper>
|
</InputWrapper>
|
||||||
</template>
|
</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({
|
|
||||||
...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({
|
<script setup>
|
||||||
open: false,
|
import { ref, onMounted, watch, reactive } from 'vue'
|
||||||
onInsert: (mention) => {
|
import { inputProps, useFormInput } from './useFormInput.js'
|
||||||
insertMention(mention)
|
import InputWrapper from './components/InputWrapper.vue'
|
||||||
mentionState.value.open = false
|
import MentionDropdown from './components/MentionDropdown.vue'
|
||||||
},
|
|
||||||
onCancel: () => {
|
const props = defineProps({
|
||||||
mentionState.value.open = false
|
...inputProps,
|
||||||
restoreSelection()
|
mentions: { type: Array, default: () => [] },
|
||||||
},
|
disableMention: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
const createMentionSpan = (mention) => {
|
|
||||||
const mentionSpan = document.createElement('span')
|
const emit = defineEmits(['update:modelValue'])
|
||||||
mentionSpan.setAttribute('mention-field-id', mention.field.id)
|
|
||||||
mentionSpan.setAttribute('mention-field-name', mention.field.name)
|
const { compVal, inputStyle, hasError, inputWrapperProps } = useFormInput(props, { emit })
|
||||||
mentionSpan.setAttribute('mention-fallback', mention.fallback || '')
|
const editableDiv = ref(null)
|
||||||
mentionSpan.setAttribute('contenteditable', 'false')
|
const savedRange = ref(null)
|
||||||
mentionSpan.setAttribute('mention', 'true')
|
const subscriptionModalStore = useSubscriptionModalStore()
|
||||||
mentionSpan.textContent = mention.field.name.length > 25 ? `${mention.field.name.slice(0, 25)}...` : mention.field.name
|
|
||||||
return mentionSpan
|
// Create a reactive state object for the mention dropdown
|
||||||
}
|
const mentionState = reactive({
|
||||||
const insertMention = (mention) => {
|
open: false,
|
||||||
const mentionSpan = createMentionSpan(mention)
|
onInsert: (mention) => {
|
||||||
|
insertMention(mention)
|
||||||
|
mentionState.open = false
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
mentionState.open = false
|
||||||
restoreSelection()
|
restoreSelection()
|
||||||
const range = window.getSelection().getRangeAt(0)
|
},
|
||||||
// Insert the mention span
|
})
|
||||||
range.insertNode(mentionSpan)
|
|
||||||
|
const createMentionSpan = (mention) => {
|
||||||
// Move the cursor after the inserted mention
|
const mentionSpan = document.createElement('span')
|
||||||
range.setStartAfter(mentionSpan)
|
mentionSpan.setAttribute('mention', 'true')
|
||||||
range.collapse(true)
|
mentionSpan.setAttribute('mention-field-id', mention.field.id)
|
||||||
// Apply the new selection
|
mentionSpan.setAttribute('mention-field-name', mention.field.name)
|
||||||
|
mentionSpan.setAttribute('mention-fallback', mention.fallback || '')
|
||||||
|
mentionSpan.setAttribute('contenteditable', 'false')
|
||||||
|
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 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()
|
const selection = window.getSelection()
|
||||||
selection.removeAllRanges()
|
selection.removeAllRanges()
|
||||||
selection.addRange(range)
|
selection.addRange(range)
|
||||||
// Ensure the editableDiv is focused
|
savedRange.value = range
|
||||||
editableDiv.value.focus()
|
|
||||||
updateCompVal()
|
|
||||||
}
|
}
|
||||||
const openMentionDropdown = () => {
|
mentionState.open = true
|
||||||
if (props.disableMention) {
|
}
|
||||||
subscriptionModalStore.setModalContent('Upgrade to Pro', 'Upgrade to Pro to use mentions')
|
|
||||||
subscriptionModalStore.openModal()
|
const saveSelection = () => {
|
||||||
return
|
const selection = window.getSelection()
|
||||||
}
|
if (selection.rangeCount > 0) {
|
||||||
|
savedRange.value = selection.getRangeAt(0)
|
||||||
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 restoreSelection = () => {
|
||||||
|
if (savedRange.value) {
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (selection.rangeCount > 0) {
|
selection.removeAllRanges()
|
||||||
savedRange.value = selection.getRangeAt(0)
|
selection.addRange(savedRange.value)
|
||||||
}
|
editableDiv.value.focus()
|
||||||
}
|
}
|
||||||
const restoreSelection = () => {
|
}
|
||||||
if (savedRange.value) {
|
|
||||||
const selection = window.getSelection()
|
const updateCompVal = () => {
|
||||||
selection.removeAllRanges()
|
compVal.value = editableDiv.value.innerHTML
|
||||||
selection.addRange(savedRange.value)
|
}
|
||||||
editableDiv.value.focus()
|
|
||||||
}
|
const onInput = () => {
|
||||||
|
updateCompVal()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (compVal.value) {
|
||||||
|
editableDiv.value.innerHTML = compVal.value
|
||||||
}
|
}
|
||||||
const updateCompVal = () => {
|
})
|
||||||
compVal.value = editableDiv.value.innerHTML
|
|
||||||
|
watch(compVal, (newVal) => {
|
||||||
|
if (editableDiv.value && editableDiv.value.innerHTML !== newVal) {
|
||||||
|
editableDiv.value.innerHTML = newVal
|
||||||
}
|
}
|
||||||
const onInput = () => {
|
})
|
||||||
updateCompVal()
|
|
||||||
}
|
defineExpose({
|
||||||
onMounted(() => {
|
editableDiv,
|
||||||
if (compVal.value) {
|
compVal,
|
||||||
editableDiv.value.innerHTML = compVal.value
|
mentionState,
|
||||||
}
|
openMentionDropdown,
|
||||||
})
|
onInput,
|
||||||
watch(compVal, (newVal) => {
|
})
|
||||||
if (editableDiv.value && editableDiv.value.innerHTML !== newVal) {
|
</script>
|
||||||
editableDiv.value.innerHTML = newVal
|
|
||||||
}
|
<style scoped>
|
||||||
})
|
.mention-input {
|
||||||
defineExpose({
|
min-height: 1.5rem;
|
||||||
editableDiv,
|
white-space: pre-wrap;
|
||||||
compVal,
|
word-break: break-word;
|
||||||
mentionState,
|
}
|
||||||
openMentionDropdown,
|
|
||||||
onInput,
|
.mention-input:empty::before {
|
||||||
})
|
content: attr(placeholder);
|
||||||
</script>
|
color: #9ca3af;
|
||||||
|
}
|
||||||
<style scoped>
|
|
||||||
.mention-input {
|
.mention-input span[mention] {
|
||||||
min-height: 1.5rem;
|
max-width: 150px;
|
||||||
white-space: pre-wrap;
|
overflow: hidden;
|
||||||
word-break: break-word;
|
text-overflow: ellipsis;
|
||||||
}
|
white-space: nowrap;
|
||||||
.mention-input:empty::before {
|
display: inline-flex;
|
||||||
content: attr(placeholder);
|
align-items: center;
|
||||||
color: #9ca3af;
|
background-color: #dbeafe;
|
||||||
}
|
color: #1e40af;
|
||||||
.mention-input span[mention] {
|
border: 1px solid #bfdbfe;
|
||||||
max-width: 150px;
|
border-radius: 0.25rem;
|
||||||
overflow: hidden;
|
padding: 0 0.25rem;
|
||||||
text-overflow: ellipsis;
|
font-size: 0.875rem;
|
||||||
white-space: nowrap;
|
line-height: 1.25rem;
|
||||||
display: inline-flex;
|
position: relative;
|
||||||
align-items: center;
|
vertical-align: baseline;
|
||||||
background-color: #dbeafe;
|
}
|
||||||
color: #1e40af;
|
</style>
|
||||||
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>
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
:options="quillOptions"
|
:options="quillOptions"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:style="inputStyle"
|
:style="inputStyle"
|
||||||
|
@ready="onEditorReady"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -59,7 +60,7 @@
|
||||||
|
|
||||||
<MentionDropdown
|
<MentionDropdown
|
||||||
v-if="enableMentions && mentionState"
|
v-if="enableMentions && mentionState"
|
||||||
:state="mentionState"
|
:mention-state="mentionState"
|
||||||
:mentions="mentions"
|
:mentions="mentions"
|
||||||
/>
|
/>
|
||||||
</InputWrapper>
|
</InputWrapper>
|
||||||
|
|
@ -103,12 +104,23 @@ watch(compVal, (val) => {
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// Move the mention extension registration to onMounted
|
// Initialize mention extension
|
||||||
|
if (props.enableMentions) {
|
||||||
if (props.enableMentions && !Quill.imports['blots/mention']) {
|
// Register the mention extension with Quill
|
||||||
mentionState.value = registerMentionExtension(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 quillOptions = computed(() => {
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
placeholder: props.placeholder || '',
|
placeholder: props.placeholder || '',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<UPopover
|
<UPopover
|
||||||
ref="popover"
|
ref="popover"
|
||||||
v-model:open="open"
|
v-model:open="mentionState.open"
|
||||||
class="h-0"
|
class="h-0"
|
||||||
@close="cancel"
|
@close="cancel"
|
||||||
>
|
>
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex border-t pt-2 -mx-2 px-2 justify-end space-x-2">
|
<div class="flex border-t pt-2 -mx-2 px-2 justify-end space-x-2">
|
||||||
<UButton
|
<UButton
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -66,51 +66,62 @@
|
||||||
</template>
|
</template>
|
||||||
</UPopover>
|
</UPopover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, toRefs } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import BlockTypeIcon from '~/components/open/forms/components/BlockTypeIcon.vue'
|
import BlockTypeIcon from '~/components/open/forms/components/BlockTypeIcon.vue'
|
||||||
import blocksTypes from '~/data/blocks_types.json'
|
import blocksTypes from '~/data/blocks_types.json'
|
||||||
const props = defineProps({
|
|
||||||
state: Object,
|
const props = defineProps({
|
||||||
mentions: Array
|
mentionState: {
|
||||||
})
|
type: Object,
|
||||||
defineShortcuts({
|
required: true
|
||||||
escape: () => {
|
},
|
||||||
open.value = false
|
mentions: {
|
||||||
}
|
type: Array,
|
||||||
})
|
required: true
|
||||||
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
|
defineShortcuts({
|
||||||
fallbackValue.value = ''
|
escape: () => {
|
||||||
}
|
props.mentionState.open = false
|
||||||
})
|
|
||||||
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()
|
const selectedField = ref(null)
|
||||||
}
|
const fallbackValue = ref('')
|
||||||
open.value = false
|
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
</script>
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -35,13 +35,40 @@ let quillInstance = null
|
||||||
const container = ref(null)
|
const container = ref(null)
|
||||||
const model = ref(props.modelValue)
|
const model = ref(props.modelValue)
|
||||||
|
|
||||||
|
// Safely paste HTML content with handling for empty content
|
||||||
const pasteHTML = (instance) => {
|
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 = () => {
|
const initializeQuill = () => {
|
||||||
if (container.value) {
|
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) => {
|
quillInstance.on('selection-change', (range, oldRange, source) => {
|
||||||
if (!range) {
|
if (!range) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-show="isActive"
|
v-if="isActive"
|
||||||
class="settings-section"
|
class="settings-section"
|
||||||
>
|
>
|
||||||
<h3 class="text-xl font-medium mb-1">
|
<h3 class="text-xl font-medium mb-1">
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@ const triggerSubmit = async () => {
|
||||||
submissionId: submissionId.value
|
submissionId: submissionId.value
|
||||||
}).then(result => {
|
}).then(result => {
|
||||||
if (result) {
|
if (result) {
|
||||||
submittedData.value = result || {}
|
submittedData.value = formManager.form.data()
|
||||||
|
|
||||||
if (result?.submission_id) {
|
if (result?.submission_id) {
|
||||||
submissionId.value = result.submission_id
|
submissionId.value = result.submission_id
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
:options="visibilityOptions"
|
:options="visibilityOptions"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="form.closes_at || form.visibility == 'closed'"
|
v-if="isFormClosingOrClosed"
|
||||||
class="bg-gray-50 border rounded-lg px-4 py-2"
|
class="bg-gray-50 border rounded-lg px-4 py-2"
|
||||||
>
|
>
|
||||||
<rich-text-area-input
|
<rich-text-area-input
|
||||||
|
|
@ -112,119 +112,103 @@
|
||||||
</modal>
|
</modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import clonedeep from "clone-deep"
|
import clonedeep from 'clone-deep'
|
||||||
import { default as _has } from "lodash/has"
|
import { default as _has } from 'lodash/has'
|
||||||
|
|
||||||
export default {
|
// Store setup
|
||||||
setup() {
|
const formsStore = useFormsStore()
|
||||||
const formsStore = useFormsStore()
|
const workingFormStore = useWorkingFormStore()
|
||||||
const workingFormStore = useWorkingFormStore()
|
const { content: form } = storeToRefs(workingFormStore)
|
||||||
const { getAll: forms } = storeToRefs(formsStore)
|
const forms = computed(() => formsStore.getAll)
|
||||||
|
|
||||||
|
// Reactive state
|
||||||
|
const showCopyFormSettingsModal = ref(false)
|
||||||
|
const copyFormId = ref(null)
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const visibilityOptions = [
|
||||||
|
{
|
||||||
|
name: 'Published',
|
||||||
|
value: 'public',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Draft - not publicly accessible',
|
||||||
|
value: 'draft',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Closed - won\'t accept new submissions',
|
||||||
|
value: 'closed',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const copyFormOptions = computed(() => {
|
||||||
|
return forms.value
|
||||||
|
.filter((formItem) => {
|
||||||
|
return form.value.id !== formItem.id
|
||||||
|
})
|
||||||
|
.map((formItem) => {
|
||||||
|
return {
|
||||||
|
name: formItem.title,
|
||||||
|
value: formItem.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const allTagsOptions = computed(() => {
|
||||||
|
return formsStore.allTags.map((tagname) => {
|
||||||
return {
|
return {
|
||||||
forms,
|
name: tagname,
|
||||||
formsStore,
|
value: tagname,
|
||||||
workingFormStore,
|
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
|
})
|
||||||
|
|
||||||
data() {
|
// New computed property for v-if condition
|
||||||
return {
|
const isFormClosingOrClosed = computed(() => {
|
||||||
showCopyFormSettingsModal: false,
|
return form.value.closes_at || form.value.visibility === 'closed'
|
||||||
copyFormId: null,
|
})
|
||||||
visibilityOptions: [
|
|
||||||
{
|
|
||||||
name: "Published",
|
|
||||||
value: "public",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Draft - not publicly accessible",
|
|
||||||
value: "draft",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Closed - won't accept new submissions",
|
|
||||||
value: "closed",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
// Methods
|
||||||
copyFormOptions() {
|
const copySettings = () => {
|
||||||
return this.forms
|
if (copyFormId.value == null)
|
||||||
.filter((form) => {
|
return
|
||||||
return this.form.id !== form.id
|
const copyForm = clonedeep(
|
||||||
})
|
forms.value.find(form => form.id === copyFormId.value),
|
||||||
.map((form) => {
|
)
|
||||||
return {
|
if (!copyForm)
|
||||||
name: form.title,
|
return;
|
||||||
value: form.id,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
get() {
|
|
||||||
return this.workingFormStore.content
|
|
||||||
},
|
|
||||||
/* We add a setter */
|
|
||||||
set(value) {
|
|
||||||
this.workingFormStore.set(value)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
allTagsOptions() {
|
|
||||||
return this.formsStore.allTags.map((tagname) => {
|
|
||||||
return {
|
|
||||||
name: tagname,
|
|
||||||
value: tagname,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {},
|
// Clean copy from form
|
||||||
|
[
|
||||||
|
"title",
|
||||||
|
"properties",
|
||||||
|
"cleanings",
|
||||||
|
"views_count",
|
||||||
|
"submissions_count",
|
||||||
|
"workspace",
|
||||||
|
"workspace_id",
|
||||||
|
"updated_at",
|
||||||
|
"share_url",
|
||||||
|
"slug",
|
||||||
|
"notion_database_url",
|
||||||
|
"id",
|
||||||
|
"database_id",
|
||||||
|
"database_fields_update",
|
||||||
|
"creator",
|
||||||
|
"created_at",
|
||||||
|
"deleted_at",
|
||||||
|
"last_edited_human",
|
||||||
|
].forEach((property) => {
|
||||||
|
if (_has(copyForm, property))
|
||||||
|
delete copyForm[property]
|
||||||
|
})
|
||||||
|
|
||||||
mounted() {},
|
// Apply changes
|
||||||
|
Object.keys(copyForm).forEach((property) => {
|
||||||
methods: {
|
form.value[property] = copyForm[property]
|
||||||
copySettings() {
|
})
|
||||||
if (this.copyFormId == null) return
|
showCopyFormSettingsModal.value = false
|
||||||
const copyForm = clonedeep(
|
useAlert().success('Form settings copied.')
|
||||||
this.forms.find((form) => form.id === this.copyFormId),
|
|
||||||
)
|
|
||||||
if (!copyForm) return;
|
|
||||||
|
|
||||||
// Clean copy from form
|
|
||||||
[
|
|
||||||
"title",
|
|
||||||
"properties",
|
|
||||||
"cleanings",
|
|
||||||
"views_count",
|
|
||||||
"submissions_count",
|
|
||||||
"workspace",
|
|
||||||
"workspace_id",
|
|
||||||
"updated_at",
|
|
||||||
"share_url",
|
|
||||||
"slug",
|
|
||||||
"notion_database_url",
|
|
||||||
"id",
|
|
||||||
"database_id",
|
|
||||||
"database_fields_update",
|
|
||||||
"creator",
|
|
||||||
"created_at",
|
|
||||||
"deleted_at",
|
|
||||||
"last_edited_human",
|
|
||||||
].forEach((property) => {
|
|
||||||
if (_has(copyForm, property)) {
|
|
||||||
delete copyForm[property]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Apply changes
|
|
||||||
Object.keys(copyForm).forEach((property) => {
|
|
||||||
this.form[property] = copyForm[property]
|
|
||||||
})
|
|
||||||
this.showCopyFormSettingsModal = false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,177 +1,198 @@
|
||||||
import { reactive } from 'vue'
|
import { reactive, nextTick } from 'vue'
|
||||||
import Quill from 'quill'
|
import Quill from 'quill'
|
||||||
const Inline = Quill.import('blots/inline')
|
|
||||||
const Clipboard = Quill.import('modules/clipboard')
|
|
||||||
|
|
||||||
export default function registerMentionExtension(Quill) {
|
// Core imports
|
||||||
// Extend Clipboard to handle pasted content
|
const ParchmentEmbed = Quill.import('parchment').EmbedBlot
|
||||||
class MentionClipboard extends Clipboard {
|
const Delta = Quill.import('delta')
|
||||||
convert(html) {
|
const Parchment = Quill.import('parchment')
|
||||||
const delta = super.convert(html)
|
|
||||||
const processedDelta = delta.ops.reduce((newDelta, op) => {
|
/**
|
||||||
if (op.attributes && op.attributes.mention) {
|
* Utility to remove BOM and other zero-width characters from a string.
|
||||||
const mentionData = op.attributes.mention
|
*/
|
||||||
let isValid = false
|
function cleanString(str) {
|
||||||
// Check for nested structure
|
if (typeof str !== 'string') return ''
|
||||||
if (
|
return str.replace(/\uFEFF/g, '').replace(/\s+/g, ' ').trim()
|
||||||
mentionData &&
|
}
|
||||||
typeof mentionData === 'object' &&
|
|
||||||
mentionData.field &&
|
export default function registerMentionExtension(QuillInstance) {
|
||||||
typeof mentionData.field === 'object' &&
|
/**
|
||||||
mentionData.field.id
|
* MentionBlot - Embeds a mention as a non-editable element
|
||||||
) {
|
*/
|
||||||
isValid = true
|
if (!QuillInstance.imports['formats/mention']) {
|
||||||
} else if (
|
class MentionBlot extends ParchmentEmbed {
|
||||||
mentionData &&
|
static blotName = 'mention'
|
||||||
typeof mentionData === 'object' &&
|
static tagName = 'SPAN'
|
||||||
mentionData['mention-field-id']
|
static scope = Parchment.Scope.INLINE
|
||||||
) {
|
|
||||||
// Transform flat structure to nested structure
|
// Match any span with a 'mention' attribute
|
||||||
op.attributes.mention = {
|
static matches(domNode) {
|
||||||
field: {
|
return (
|
||||||
id: mentionData['mention-field-id'],
|
domNode instanceof HTMLElement &&
|
||||||
name: mentionData['mention-field-name'] || '',
|
domNode.tagName === this.tagName &&
|
||||||
},
|
domNode.hasAttribute('mention')
|
||||||
fallback: mentionData['mention-fallback'] || '',
|
)
|
||||||
}
|
}
|
||||||
isValid = true
|
|
||||||
}
|
static create(value) {
|
||||||
if (!isValid) {
|
const node = document.createElement(this.tagName)
|
||||||
delete op.attributes.mention
|
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) {
|
static formats(domNode) {
|
||||||
// Only set attributes if we have valid data
|
return MentionBlot.value(domNode)
|
||||||
if (!data || !data.field || !data.field.id) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
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) {
|
length() {
|
||||||
return {
|
return 1
|
||||||
'mention-field-id': domNode.getAttribute('mention-field-id') || '',
|
|
||||||
'mention-field-name': domNode.getAttribute('mention-field-name') || '',
|
|
||||||
'mention-fallback': domNode.getAttribute('mention-fallback') || ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
format(name, value) {
|
// Register the blot with Quill
|
||||||
if (name === 'mention' && value) {
|
QuillInstance.register('formats/mention', MentionBlot)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
* MentionModule - Handles mention UI integration with Quill
|
||||||
onInsert: null,
|
*/
|
||||||
onCancel: null,
|
if (!QuillInstance.imports['modules/mention']) {
|
||||||
})
|
class MentionModule {
|
||||||
|
constructor(quill, options = {}) {
|
||||||
class MentionModule {
|
this.quill = quill
|
||||||
constructor(quill, options) {
|
this.options = options
|
||||||
this.quill = quill
|
|
||||||
this.options = options
|
// Reactive state for the UI component
|
||||||
|
this.state = reactive({
|
||||||
this.setupMentions()
|
open: false,
|
||||||
}
|
onInsert: null,
|
||||||
|
onCancel: null
|
||||||
setupMentions() {
|
})
|
||||||
const toolbar = this.quill.getModule('toolbar')
|
|
||||||
if (toolbar) {
|
this.setupMentions()
|
||||||
toolbar.addHandler('mention', () => {
|
}
|
||||||
const range = this.quill.getSelection()
|
|
||||||
if (range) {
|
setupMentions() {
|
||||||
mentionState.open = true
|
const toolbar = this.quill.getModule('toolbar')
|
||||||
mentionState.onInsert = (mention) => {
|
if (toolbar) {
|
||||||
this.insertMention(mention, range.index)
|
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) {
|
// Register the module
|
||||||
mentionState.open = false
|
QuillInstance.register('modules/mention', MentionModule)
|
||||||
|
}
|
||||||
// Insert the mention
|
|
||||||
this.quill.insertEmbed(index, 'mention', mention, Quill.sources.USER)
|
// Patch getSemanticHTML to handle non-breaking spaces
|
||||||
|
if (typeof Quill.prototype.getSemanticHTML === 'function') {
|
||||||
// Calculate the length of the inserted mention
|
if (!Quill.prototype.getSemanticHTML.isPatched) {
|
||||||
const mentionLength = this.quill.getLength() - index
|
const originalGetSemanticHTML = Quill.prototype.getSemanticHTML
|
||||||
|
Quill.prototype.getSemanticHTML = function(index = 0, length) {
|
||||||
nextTick(() => {
|
const currentLength = this.getLength()
|
||||||
// Focus the editor
|
const sanitizedIndex = Math.max(0, index)
|
||||||
this.quill.focus()
|
const sanitizedLength = Math.max(0, Math.min(length ?? (currentLength - sanitizedIndex), currentLength - sanitizedIndex))
|
||||||
|
if (sanitizedIndex >= currentLength && currentLength > 0) {
|
||||||
// Set the selection after the mention
|
return originalGetSemanticHTML.call(this, 0, 0)
|
||||||
this.quill.setSelection(index + mentionLength, 0, Quill.sources.SILENT)
|
}
|
||||||
})
|
const html = originalGetSemanticHTML.call(this, sanitizedIndex, sanitizedLength)
|
||||||
|
return html.replace(/ |\u00A0/g, ' ')
|
||||||
|
}
|
||||||
|
Quill.prototype.getSemanticHTML.isPatched = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Quill.register('modules/mention', MentionModule)
|
// Return reactive state for component binding
|
||||||
|
return reactive({
|
||||||
return mentionState
|
open: false,
|
||||||
|
onInsert: null,
|
||||||
|
onCancel: null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue