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 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);
}
} }
} }

View File

@ -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>';

View File

@ -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>

View File

@ -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 || '',

View File

@ -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>

View File

@ -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) {

View File

@ -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">

View File

@ -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

View File

@ -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>

View File

@ -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(/&nbsp;|\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
})
} }