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 DOMXPath;
|
||||
use DOMElement;
|
||||
|
||||
class MentionParser
|
||||
{
|
||||
|
|
@ -39,9 +40,11 @@ class MentionParser
|
|||
libxml_use_internal_errors($internalErrors);
|
||||
|
||||
$xpath = new DOMXPath($doc);
|
||||
$mentionElements = $xpath->query("//span[@mention]");
|
||||
|
||||
$mentionElements = $xpath->query("//span[@mention or @mention='true']");
|
||||
|
||||
foreach ($mentionElements as $element) {
|
||||
if ($element instanceof DOMElement) {
|
||||
$fieldId = $element->getAttribute('mention-field-id');
|
||||
$fallback = $element->getAttribute('mention-fallback');
|
||||
$value = $this->getData($fieldId);
|
||||
|
|
@ -56,6 +59,7 @@ class MentionParser
|
|||
$element->parentNode->removeChild($element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and return the processed HTML content
|
||||
$result = $doc->saveHTML($doc->getElementsByTagName('root')->item(0));
|
||||
|
|
|
|||
|
|
@ -35,6 +35,18 @@ describe('MentionParser', function () {
|
|||
expect($result)->toBe('<div>Hello !</div>');
|
||||
});
|
||||
|
||||
it('supports mention="true" syntax', function () {
|
||||
$content = '<p>Hello <span mention="true" mention-field-id="123" mention-fallback="">Full Name</span></p>';
|
||||
$data = [
|
||||
['id' => '123', 'value' => 'John Doe']
|
||||
];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Hello John Doe</p>');
|
||||
});
|
||||
|
||||
describe('parseAsText', function () {
|
||||
it('converts HTML to plain text with proper line breaks', function () {
|
||||
$content = '<div>First line</div><div>Second line</div>';
|
||||
|
|
@ -144,6 +156,29 @@ describe('MentionParser', function () {
|
|||
expect($result)->toBe('<p>Tags: PHP, Laravel, Testing</p>');
|
||||
});
|
||||
|
||||
test('it supports mention="true" attributes', function () {
|
||||
$content = '<p>Hello <span contenteditable="false" mention="true" mention-field-id="123" mention-field-name="Full Name" mention-fallback="">Full Name</span></p>';
|
||||
$data = [['id' => '123', 'value' => 'John Doe']];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Hello John Doe</p>');
|
||||
});
|
||||
|
||||
test('it handles multiple mentions with mention="true" syntax', function () {
|
||||
$content = '<p><span contenteditable="false" mention="true" mention-field-id="123" mention-field-name="Name" mention-fallback="">Name</span> and <span contenteditable="false" mention="true" mention-field-id="456" mention-field-name="Title" mention-fallback="">Title</span></p>';
|
||||
$data = [
|
||||
['id' => '123', 'value' => 'John Doe'],
|
||||
['id' => '456', 'value' => 'Developer'],
|
||||
];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>John Doe and Developer</p>');
|
||||
});
|
||||
|
||||
test('it preserves HTML structure', function () {
|
||||
$content = '<div><p>Hello <span mention mention-field-id="123">Placeholder</span></p><p>How are you?</p></div>';
|
||||
$data = [['id' => '123', 'value' => 'World']];
|
||||
|
|
@ -174,6 +209,53 @@ describe('MentionParser', function () {
|
|||
expect($result)->toBe('some text replaced text dewde');
|
||||
});
|
||||
|
||||
test('it handles URL-encoded field IDs', function () {
|
||||
$content = '<p>Hello <span mention mention-field-id="%3ARGE" mention-fallback="">Full Name</span></p>';
|
||||
$data = [['id' => '%3ARGE', 'value' => 'John Doe']];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Hello John Doe</p>');
|
||||
});
|
||||
|
||||
test('it handles URL-encoded field IDs with mention="true" syntax', function () {
|
||||
$content = '<p>Hello <span contenteditable="false" mention="true" mention-field-id="%3ARGE" mention-field-name="Full Name" mention-fallback="">Full Name</span></p>';
|
||||
$data = [['id' => '%3ARGE', 'value' => 'John Doe']];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Hello John Doe</p>');
|
||||
});
|
||||
|
||||
test('it handles multiple mentions with URL-encoded IDs and mention="true" syntax', function () {
|
||||
$content = '<p><span contenteditable="false" mention="true" mention-field-id="%3ARGE" mention-field-name="Full Name" mention-fallback="">Full Name</span> and <span contenteditable="false" mention="true" mention-field-id="V%7D%40S" mention-field-name="Phone Number" mention-fallback="">Phone</span></p>';
|
||||
$data = [
|
||||
['id' => '%3ARGE', 'value' => 'John Doe'],
|
||||
['id' => 'V%7D%40S', 'value' => '123-456-7890'],
|
||||
];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>John Doe and 123-456-7890</p>');
|
||||
});
|
||||
|
||||
test('it recreates real-world example with URL-encoded IDs', function () {
|
||||
$content = '<p>Hello there 👋 </p><p>This is a confirmation that your submission was successfully saved.</p><p><span contenteditable="false" mention="true" mention-field-id="%3ARGE" mention-field-name="Full Name" mention-fallback="">Full Name</span><span contenteditable="false" mention="true" mention-field-id="title" mention-field-name="Contact Form" mention-fallback="">Contact Form</span><span contenteditable="false" mention="true" mention-field-id="V%7D%40S" mention-field-name="Phone Number" mention-fallback="">Phone Number</span></p>';
|
||||
$data = [
|
||||
['id' => '%3ARGE', 'value' => 'jujujujuju'],
|
||||
['id' => 'title', 'value' => 'jujuuj'],
|
||||
['id' => 'V%7D%40S', 'value' => '555-1234'],
|
||||
];
|
||||
|
||||
$parser = new MentionParser($content, $data);
|
||||
$result = $parser->parse();
|
||||
|
||||
expect($result)->toBe('<p>Hello there 👋 </p><p>This is a confirmation that your submission was successfully saved.</p><p>jujujujujujujuuj555-1234</p>');
|
||||
});
|
||||
|
||||
describe('urlFriendlyOutput', function () {
|
||||
test('it encodes special characters in values', function () {
|
||||
$content = '<p>Test: <span mention mention-field-id="123">Placeholder</span></p>';
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
</template>
|
||||
|
||||
<MentionDropdown
|
||||
:state="mentionState"
|
||||
:mention-state="mentionState"
|
||||
:mentions="mentions"
|
||||
/>
|
||||
|
||||
|
|
@ -55,62 +55,76 @@
|
|||
</InputWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
import MentionDropdown from './components/MentionDropdown.vue'
|
||||
const props = defineProps({
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, reactive } from 'vue'
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
import MentionDropdown from './components/MentionDropdown.vue'
|
||||
|
||||
const props = defineProps({
|
||||
...inputProps,
|
||||
mentions: { type: Array, default: () => [] },
|
||||
disableMention: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { compVal, inputStyle, hasError, inputWrapperProps } = useFormInput(props, { emit })
|
||||
const editableDiv = ref(null)
|
||||
const savedRange = ref(null)
|
||||
const subscriptionModalStore = useSubscriptionModalStore()
|
||||
})
|
||||
|
||||
const mentionState = ref({
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const { compVal, inputStyle, hasError, inputWrapperProps } = useFormInput(props, { emit })
|
||||
const editableDiv = ref(null)
|
||||
const savedRange = ref(null)
|
||||
const subscriptionModalStore = useSubscriptionModalStore()
|
||||
|
||||
// Create a reactive state object for the mention dropdown
|
||||
const mentionState = reactive({
|
||||
open: false,
|
||||
onInsert: (mention) => {
|
||||
insertMention(mention)
|
||||
mentionState.value.open = false
|
||||
mentionState.open = false
|
||||
},
|
||||
onCancel: () => {
|
||||
mentionState.value.open = false
|
||||
mentionState.open = false
|
||||
restoreSelection()
|
||||
},
|
||||
})
|
||||
const createMentionSpan = (mention) => {
|
||||
})
|
||||
|
||||
const createMentionSpan = (mention) => {
|
||||
const mentionSpan = document.createElement('span')
|
||||
mentionSpan.setAttribute('mention', 'true')
|
||||
mentionSpan.setAttribute('mention-field-id', mention.field.id)
|
||||
mentionSpan.setAttribute('mention-field-name', mention.field.name)
|
||||
mentionSpan.setAttribute('mention-fallback', mention.fallback || '')
|
||||
mentionSpan.setAttribute('contenteditable', 'false')
|
||||
mentionSpan.setAttribute('mention', 'true')
|
||||
mentionSpan.setAttribute('class', 'mention-item')
|
||||
mentionSpan.textContent = mention.field.name.length > 25 ? `${mention.field.name.slice(0, 25)}...` : mention.field.name
|
||||
return mentionSpan
|
||||
}
|
||||
const insertMention = (mention) => {
|
||||
}
|
||||
|
||||
const insertMention = (mention) => {
|
||||
const mentionSpan = createMentionSpan(mention)
|
||||
|
||||
restoreSelection()
|
||||
|
||||
const range = window.getSelection().getRangeAt(0)
|
||||
|
||||
// Insert the mention span
|
||||
range.insertNode(mentionSpan)
|
||||
|
||||
// Move the cursor after the inserted mention
|
||||
range.setStartAfter(mentionSpan)
|
||||
range.collapse(true)
|
||||
|
||||
// Apply the new selection
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
// Ensure the editableDiv is focused
|
||||
editableDiv.value.focus()
|
||||
|
||||
updateCompVal()
|
||||
}
|
||||
const openMentionDropdown = () => {
|
||||
}
|
||||
|
||||
const openMentionDropdown = () => {
|
||||
if (props.disableMention) {
|
||||
subscriptionModalStore.setModalContent('Upgrade to Pro', 'Upgrade to Pro to use mentions')
|
||||
subscriptionModalStore.openModal()
|
||||
|
|
@ -128,58 +142,67 @@
|
|||
selection.addRange(range)
|
||||
savedRange.value = range
|
||||
}
|
||||
mentionState.value.open = true
|
||||
}
|
||||
const saveSelection = () => {
|
||||
mentionState.open = true
|
||||
}
|
||||
|
||||
const saveSelection = () => {
|
||||
const selection = window.getSelection()
|
||||
if (selection.rangeCount > 0) {
|
||||
savedRange.value = selection.getRangeAt(0)
|
||||
}
|
||||
}
|
||||
const restoreSelection = () => {
|
||||
}
|
||||
|
||||
const restoreSelection = () => {
|
||||
if (savedRange.value) {
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(savedRange.value)
|
||||
editableDiv.value.focus()
|
||||
}
|
||||
}
|
||||
const updateCompVal = () => {
|
||||
}
|
||||
|
||||
const updateCompVal = () => {
|
||||
compVal.value = editableDiv.value.innerHTML
|
||||
}
|
||||
const onInput = () => {
|
||||
}
|
||||
|
||||
const onInput = () => {
|
||||
updateCompVal()
|
||||
}
|
||||
onMounted(() => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (compVal.value) {
|
||||
editableDiv.value.innerHTML = compVal.value
|
||||
}
|
||||
})
|
||||
watch(compVal, (newVal) => {
|
||||
})
|
||||
|
||||
watch(compVal, (newVal) => {
|
||||
if (editableDiv.value && editableDiv.value.innerHTML !== newVal) {
|
||||
editableDiv.value.innerHTML = newVal
|
||||
}
|
||||
})
|
||||
defineExpose({
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
editableDiv,
|
||||
compVal,
|
||||
mentionState,
|
||||
openMentionDropdown,
|
||||
onInput,
|
||||
})
|
||||
</script>
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mention-input {
|
||||
<style scoped>
|
||||
.mention-input {
|
||||
min-height: 1.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.mention-input:empty::before {
|
||||
}
|
||||
|
||||
.mention-input:empty::before {
|
||||
content: attr(placeholder);
|
||||
color: #9ca3af;
|
||||
}
|
||||
.mention-input span[mention] {
|
||||
}
|
||||
|
||||
.mention-input span[mention] {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
|
@ -195,5 +218,5 @@
|
|||
line-height: 1.25rem;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
:options="quillOptions"
|
||||
:disabled="disabled"
|
||||
:style="inputStyle"
|
||||
@ready="onEditorReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -59,7 +60,7 @@
|
|||
|
||||
<MentionDropdown
|
||||
v-if="enableMentions && mentionState"
|
||||
:state="mentionState"
|
||||
:mention-state="mentionState"
|
||||
:mentions="mentions"
|
||||
/>
|
||||
</InputWrapper>
|
||||
|
|
@ -103,12 +104,23 @@ watch(compVal, (val) => {
|
|||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Move the mention extension registration to onMounted
|
||||
|
||||
if (props.enableMentions && !Quill.imports['blots/mention']) {
|
||||
// Initialize mention extension
|
||||
if (props.enableMentions) {
|
||||
// Register the mention extension with Quill
|
||||
mentionState.value = registerMentionExtension(Quill)
|
||||
}
|
||||
|
||||
// Handle editor ready event
|
||||
const onEditorReady = (quillInstance) => {
|
||||
// If we have a mention module, get its state
|
||||
if (props.enableMentions && quillInstance) {
|
||||
const mentionModule = quillInstance.getModule('mention')
|
||||
if (mentionModule && mentionModule.state) {
|
||||
mentionState.value = mentionModule.state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const quillOptions = computed(() => {
|
||||
const defaultOptions = {
|
||||
placeholder: props.placeholder || '',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<UPopover
|
||||
ref="popover"
|
||||
v-model:open="open"
|
||||
v-model:open="mentionState.open"
|
||||
class="h-0"
|
||||
@close="cancel"
|
||||
>
|
||||
|
|
@ -67,50 +67,61 @@
|
|||
</UPopover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, toRefs } from 'vue'
|
||||
import BlockTypeIcon from '~/components/open/forms/components/BlockTypeIcon.vue'
|
||||
import blocksTypes from '~/data/blocks_types.json'
|
||||
const props = defineProps({
|
||||
state: Object,
|
||||
mentions: Array
|
||||
})
|
||||
defineShortcuts({
|
||||
escape: () => {
|
||||
open.value = false
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import BlockTypeIcon from '~/components/open/forms/components/BlockTypeIcon.vue'
|
||||
import blocksTypes from '~/data/blocks_types.json'
|
||||
|
||||
const props = defineProps({
|
||||
mentionState: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
mentions: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
const { open, onInsert, onCancel } = toRefs(props.state)
|
||||
const selectedField = ref(null)
|
||||
const fallbackValue = ref('')
|
||||
const filteredMentions = computed(() => {
|
||||
})
|
||||
|
||||
defineShortcuts({
|
||||
escape: () => {
|
||||
props.mentionState.open = false
|
||||
}
|
||||
})
|
||||
|
||||
const selectedField = ref(null)
|
||||
const fallbackValue = ref('')
|
||||
|
||||
const filteredMentions = computed(() => {
|
||||
return props.mentions.filter(mention => blocksTypes[mention.type]?.is_input ?? false)
|
||||
})
|
||||
function selectField(field, insert = false) {
|
||||
})
|
||||
|
||||
function selectField(field, insert = false) {
|
||||
selectedField.value = {...field}
|
||||
if (insert) {
|
||||
insertMention()
|
||||
}
|
||||
}
|
||||
watch(open, (newValue) => {
|
||||
}
|
||||
|
||||
watch(() => props.mentionState.open, (newValue) => {
|
||||
if (newValue) {
|
||||
selectedField.value = null
|
||||
fallbackValue.value = ''
|
||||
}
|
||||
})
|
||||
const insertMention = () => {
|
||||
if (selectedField.value && onInsert.value) {
|
||||
onInsert.value({
|
||||
})
|
||||
|
||||
const insertMention = () => {
|
||||
if (selectedField.value && props.mentionState.onInsert) {
|
||||
props.mentionState.onInsert({
|
||||
field: selectedField.value,
|
||||
fallback: fallbackValue.value
|
||||
})
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (props.mentionState.onCancel) {
|
||||
props.mentionState.onCancel()
|
||||
}
|
||||
const cancel = () => {
|
||||
if (onCancel.value) {
|
||||
onCancel.value()
|
||||
}
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
}
|
||||
</script>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div
|
||||
v-show="isActive"
|
||||
v-if="isActive"
|
||||
class="settings-section"
|
||||
>
|
||||
<h3 class="text-xl font-medium mb-1">
|
||||
|
|
|
|||
|
|
@ -343,7 +343,7 @@ const triggerSubmit = async () => {
|
|||
submissionId: submissionId.value
|
||||
}).then(result => {
|
||||
if (result) {
|
||||
submittedData.value = result || {}
|
||||
submittedData.value = formManager.form.data()
|
||||
|
||||
if (result?.submission_id) {
|
||||
submissionId.value = result.submission_id
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
:options="visibilityOptions"
|
||||
/>
|
||||
<div
|
||||
v-if="form.closes_at || form.visibility == 'closed'"
|
||||
v-if="isFormClosingOrClosed"
|
||||
class="bg-gray-50 border rounded-lg px-4 py-2"
|
||||
>
|
||||
<rich-text-area-input
|
||||
|
|
@ -112,86 +112,72 @@
|
|||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import clonedeep from "clone-deep"
|
||||
import { default as _has } from "lodash/has"
|
||||
<script setup>
|
||||
import clonedeep from 'clone-deep'
|
||||
import { default as _has } from 'lodash/has'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const formsStore = useFormsStore()
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
const { getAll: forms } = storeToRefs(formsStore)
|
||||
return {
|
||||
forms,
|
||||
formsStore,
|
||||
workingFormStore,
|
||||
}
|
||||
},
|
||||
// Store setup
|
||||
const formsStore = useFormsStore()
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
const { content: form } = storeToRefs(workingFormStore)
|
||||
const forms = computed(() => formsStore.getAll)
|
||||
|
||||
data() {
|
||||
return {
|
||||
showCopyFormSettingsModal: false,
|
||||
copyFormId: null,
|
||||
visibilityOptions: [
|
||||
// Reactive state
|
||||
const showCopyFormSettingsModal = ref(false)
|
||||
const copyFormId = ref(null)
|
||||
|
||||
// Computed properties
|
||||
const visibilityOptions = [
|
||||
{
|
||||
name: "Published",
|
||||
value: "public",
|
||||
name: 'Published',
|
||||
value: 'public',
|
||||
},
|
||||
{
|
||||
name: "Draft - not publicly accessible",
|
||||
value: "draft",
|
||||
name: 'Draft - not publicly accessible',
|
||||
value: 'draft',
|
||||
},
|
||||
{
|
||||
name: "Closed - won't accept new submissions",
|
||||
value: "closed",
|
||||
},
|
||||
],
|
||||
}
|
||||
name: 'Closed - won\'t accept new submissions',
|
||||
value: 'closed',
|
||||
},
|
||||
]
|
||||
|
||||
computed: {
|
||||
copyFormOptions() {
|
||||
return this.forms
|
||||
.filter((form) => {
|
||||
return this.form.id !== form.id
|
||||
const copyFormOptions = computed(() => {
|
||||
return forms.value
|
||||
.filter((formItem) => {
|
||||
return form.value.id !== formItem.id
|
||||
})
|
||||
.map((form) => {
|
||||
.map((formItem) => {
|
||||
return {
|
||||
name: form.title,
|
||||
value: form.id,
|
||||
name: formItem.title,
|
||||
value: formItem.id,
|
||||
}
|
||||
})
|
||||
},
|
||||
form: {
|
||||
get() {
|
||||
return this.workingFormStore.content
|
||||
},
|
||||
/* We add a setter */
|
||||
set(value) {
|
||||
this.workingFormStore.set(value)
|
||||
},
|
||||
},
|
||||
allTagsOptions() {
|
||||
return this.formsStore.allTags.map((tagname) => {
|
||||
})
|
||||
|
||||
const allTagsOptions = computed(() => {
|
||||
return formsStore.allTags.map((tagname) => {
|
||||
return {
|
||||
name: tagname,
|
||||
value: tagname,
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
watch: {},
|
||||
// New computed property for v-if condition
|
||||
const isFormClosingOrClosed = computed(() => {
|
||||
return form.value.closes_at || form.value.visibility === 'closed'
|
||||
})
|
||||
|
||||
mounted() {},
|
||||
|
||||
methods: {
|
||||
copySettings() {
|
||||
if (this.copyFormId == null) return
|
||||
// Methods
|
||||
const copySettings = () => {
|
||||
if (copyFormId.value == null)
|
||||
return
|
||||
const copyForm = clonedeep(
|
||||
this.forms.find((form) => form.id === this.copyFormId),
|
||||
forms.value.find(form => form.id === copyFormId.value),
|
||||
)
|
||||
if (!copyForm) return;
|
||||
if (!copyForm)
|
||||
return;
|
||||
|
||||
// Clean copy from form
|
||||
[
|
||||
|
|
@ -214,17 +200,15 @@ export default {
|
|||
"deleted_at",
|
||||
"last_edited_human",
|
||||
].forEach((property) => {
|
||||
if (_has(copyForm, property)) {
|
||||
if (_has(copyForm, property))
|
||||
delete copyForm[property]
|
||||
}
|
||||
})
|
||||
|
||||
// Apply changes
|
||||
Object.keys(copyForm).forEach((property) => {
|
||||
this.form[property] = copyForm[property]
|
||||
form.value[property] = copyForm[property]
|
||||
})
|
||||
this.showCopyFormSettingsModal = false
|
||||
},
|
||||
},
|
||||
showCopyFormSettingsModal.value = false
|
||||
useAlert().success('Form settings copied.')
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,100 +1,58 @@
|
|||
import { reactive } from 'vue'
|
||||
import { reactive, nextTick } from 'vue'
|
||||
import Quill from 'quill'
|
||||
const Inline = Quill.import('blots/inline')
|
||||
const Clipboard = Quill.import('modules/clipboard')
|
||||
|
||||
export default function registerMentionExtension(Quill) {
|
||||
// Extend Clipboard to handle pasted content
|
||||
class MentionClipboard extends Clipboard {
|
||||
convert(html) {
|
||||
const delta = super.convert(html)
|
||||
const processedDelta = delta.ops.reduce((newDelta, op) => {
|
||||
if (op.attributes && op.attributes.mention) {
|
||||
const mentionData = op.attributes.mention
|
||||
let isValid = false
|
||||
// Check for nested structure
|
||||
if (
|
||||
mentionData &&
|
||||
typeof mentionData === 'object' &&
|
||||
mentionData.field &&
|
||||
typeof mentionData.field === 'object' &&
|
||||
mentionData.field.id
|
||||
) {
|
||||
isValid = true
|
||||
} else if (
|
||||
mentionData &&
|
||||
typeof mentionData === 'object' &&
|
||||
mentionData['mention-field-id']
|
||||
) {
|
||||
// Transform flat structure to nested structure
|
||||
op.attributes.mention = {
|
||||
field: {
|
||||
id: mentionData['mention-field-id'],
|
||||
name: mentionData['mention-field-name'] || '',
|
||||
},
|
||||
fallback: mentionData['mention-fallback'] || '',
|
||||
}
|
||||
isValid = true
|
||||
}
|
||||
if (!isValid) {
|
||||
delete op.attributes.mention
|
||||
}
|
||||
}
|
||||
newDelta.push(op)
|
||||
return newDelta
|
||||
}, [])
|
||||
return processedDelta
|
||||
}
|
||||
}
|
||||
Quill.register('modules/clipboard', MentionClipboard, true)
|
||||
// Core imports
|
||||
const ParchmentEmbed = Quill.import('parchment').EmbedBlot
|
||||
const Delta = Quill.import('delta')
|
||||
const Parchment = Quill.import('parchment')
|
||||
|
||||
class MentionBlot extends Inline {
|
||||
/**
|
||||
* Utility to remove BOM and other zero-width characters from a string.
|
||||
*/
|
||||
function cleanString(str) {
|
||||
if (typeof str !== 'string') return ''
|
||||
return str.replace(/\uFEFF/g, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export default function registerMentionExtension(QuillInstance) {
|
||||
/**
|
||||
* MentionBlot - Embeds a mention as a non-editable element
|
||||
*/
|
||||
if (!QuillInstance.imports['formats/mention']) {
|
||||
class MentionBlot extends ParchmentEmbed {
|
||||
static blotName = 'mention'
|
||||
static tagName = 'SPAN'
|
||||
static scope = Parchment.Scope.INLINE
|
||||
|
||||
static create(data) {
|
||||
// Only create mention if we have valid data
|
||||
if (!data || !data.field || !data.field.id) {
|
||||
return null
|
||||
}
|
||||
let node = super.create()
|
||||
MentionBlot.setAttributes(node, data)
|
||||
return node
|
||||
// Match any span with a 'mention' attribute
|
||||
static matches(domNode) {
|
||||
return (
|
||||
domNode instanceof HTMLElement &&
|
||||
domNode.tagName === this.tagName &&
|
||||
domNode.hasAttribute('mention')
|
||||
)
|
||||
}
|
||||
|
||||
static setAttributes(node, data) {
|
||||
// Only set attributes if we have valid data
|
||||
if (!data || !data.field || !data.field.id) {
|
||||
return
|
||||
}
|
||||
static create(value) {
|
||||
const node = document.createElement(this.tagName)
|
||||
node.setAttribute('contenteditable', 'false')
|
||||
|
||||
const data = (typeof value === 'object' && value !== null) ? value : { field: {}, fallback: '' }
|
||||
data.field = (typeof data.field === 'object' && data.field !== null) ? data.field : {}
|
||||
|
||||
const fieldName = cleanString(data.field.name)
|
||||
const fallbackText = cleanString(data.fallback)
|
||||
const displayText = fieldName || fallbackText || 'mention'
|
||||
|
||||
node.setAttribute('mention', 'true')
|
||||
node.setAttribute('mention-field-id', data.field.id || '')
|
||||
node.setAttribute('mention-field-name', data.field.name || '')
|
||||
node.setAttribute('mention-fallback', data.fallback || '')
|
||||
node.textContent = data.field.name || ''
|
||||
}
|
||||
node.setAttribute('mention-field-name', fieldName)
|
||||
node.setAttribute('mention-fallback', fallbackText)
|
||||
|
||||
static formats(domNode) {
|
||||
return {
|
||||
'mention-field-id': domNode.getAttribute('mention-field-id') || '',
|
||||
'mention-field-name': domNode.getAttribute('mention-field-name') || '',
|
||||
'mention-fallback': domNode.getAttribute('mention-fallback') || ''
|
||||
}
|
||||
}
|
||||
const textNode = document.createTextNode(displayText)
|
||||
node.appendChild(textNode)
|
||||
|
||||
format(name, value) {
|
||||
if (name === 'mention' && value) {
|
||||
MentionBlot.setAttributes(this.domNode, value)
|
||||
} else {
|
||||
super.format(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
formats() {
|
||||
let formats = super.formats()
|
||||
formats['mention'] = MentionBlot.value(this.domNode)
|
||||
return formats
|
||||
return node
|
||||
}
|
||||
|
||||
static value(domNode) {
|
||||
|
|
@ -107,10 +65,8 @@ export default function registerMentionExtension(Quill) {
|
|||
}
|
||||
}
|
||||
|
||||
// Override attach to ensure contenteditable is always set
|
||||
attach() {
|
||||
super.attach()
|
||||
this.domNode.setAttribute('contenteditable', 'false')
|
||||
static formats(domNode) {
|
||||
return MentionBlot.value(domNode)
|
||||
}
|
||||
|
||||
length() {
|
||||
|
|
@ -118,19 +74,45 @@ export default function registerMentionExtension(Quill) {
|
|||
}
|
||||
}
|
||||
|
||||
Quill.register(MentionBlot)
|
||||
// Register the blot with Quill
|
||||
QuillInstance.register('formats/mention', MentionBlot)
|
||||
}
|
||||
|
||||
const mentionState = reactive({
|
||||
open: false,
|
||||
onInsert: null,
|
||||
onCancel: null,
|
||||
// Add clipboard matcher for handling mentions in pasted/loaded HTML
|
||||
if (QuillInstance.clipboard && typeof QuillInstance.clipboard.addMatcher === 'function') {
|
||||
QuillInstance.clipboard.addMatcher('span[mention]', (node, delta) => {
|
||||
if (node.hasAttribute('mention')) {
|
||||
const mentionData = {
|
||||
field: {
|
||||
id: node.getAttribute('mention-field-id') || '',
|
||||
name: node.getAttribute('mention-field-name') || ''
|
||||
},
|
||||
fallback: node.getAttribute('mention-fallback') || ''
|
||||
}
|
||||
|
||||
return new Delta().insert({ mention: mentionData })
|
||||
}
|
||||
|
||||
return delta
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* MentionModule - Handles mention UI integration with Quill
|
||||
*/
|
||||
if (!QuillInstance.imports['modules/mention']) {
|
||||
class MentionModule {
|
||||
constructor(quill, options) {
|
||||
constructor(quill, options = {}) {
|
||||
this.quill = quill
|
||||
this.options = options
|
||||
|
||||
// Reactive state for the UI component
|
||||
this.state = reactive({
|
||||
open: false,
|
||||
onInsert: null,
|
||||
onCancel: null
|
||||
})
|
||||
|
||||
this.setupMentions()
|
||||
}
|
||||
|
||||
|
|
@ -140,38 +122,77 @@ export default function registerMentionExtension(Quill) {
|
|||
toolbar.addHandler('mention', () => {
|
||||
const range = this.quill.getSelection()
|
||||
if (range) {
|
||||
mentionState.open = true
|
||||
mentionState.onInsert = (mention) => {
|
||||
this.insertMention(mention, range.index)
|
||||
}
|
||||
mentionState.onCancel = () => {
|
||||
mentionState.open = false
|
||||
this.state.open = true
|
||||
this.state.onInsert = (mentionData) => this.insertMention(mentionData, range.index)
|
||||
this.state.onCancel = () => {
|
||||
this.state.open = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
insertMention(mention, index) {
|
||||
mentionState.open = false
|
||||
insertMention(mentionData, index) {
|
||||
if (!mentionData || typeof mentionData.field !== 'object' || mentionData.field === null) {
|
||||
console.error("Invalid mention data for insertion:", mentionData)
|
||||
return
|
||||
}
|
||||
|
||||
// Insert the mention
|
||||
this.quill.insertEmbed(index, 'mention', mention, Quill.sources.USER)
|
||||
this.state.open = false
|
||||
|
||||
// Calculate the length of the inserted mention
|
||||
const mentionLength = this.quill.getLength() - index
|
||||
// Handle selection
|
||||
const selection = this.quill.getSelection()
|
||||
if (selection && selection.length > 0) {
|
||||
this.quill.deleteText(selection.index, selection.length, QuillInstance.sources.USER)
|
||||
index = selection.index
|
||||
}
|
||||
|
||||
// Prepare clean data for the blot
|
||||
const blotData = {
|
||||
field: {
|
||||
id: mentionData.field.id || '',
|
||||
name: cleanString(mentionData.field.name)
|
||||
},
|
||||
fallback: cleanString(mentionData.fallback)
|
||||
}
|
||||
|
||||
// Insert mention as embed using the blotData
|
||||
this.quill.insertEmbed(index, 'mention', blotData, QuillInstance.sources.USER)
|
||||
|
||||
// Move cursor after mention
|
||||
nextTick(() => {
|
||||
// Focus the editor
|
||||
this.quill.focus()
|
||||
|
||||
// Set the selection after the mention
|
||||
this.quill.setSelection(index + mentionLength, 0, Quill.sources.SILENT)
|
||||
this.quill.setSelection(index + 1, 0, QuillInstance.sources.SILENT)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Quill.register('modules/mention', MentionModule)
|
||||
// Register the module
|
||||
QuillInstance.register('modules/mention', MentionModule)
|
||||
}
|
||||
|
||||
return mentionState
|
||||
// Patch getSemanticHTML to handle non-breaking spaces
|
||||
if (typeof Quill.prototype.getSemanticHTML === 'function') {
|
||||
if (!Quill.prototype.getSemanticHTML.isPatched) {
|
||||
const originalGetSemanticHTML = Quill.prototype.getSemanticHTML
|
||||
Quill.prototype.getSemanticHTML = function(index = 0, length) {
|
||||
const currentLength = this.getLength()
|
||||
const sanitizedIndex = Math.max(0, index)
|
||||
const sanitizedLength = Math.max(0, Math.min(length ?? (currentLength - sanitizedIndex), currentLength - sanitizedIndex))
|
||||
if (sanitizedIndex >= currentLength && currentLength > 0) {
|
||||
return originalGetSemanticHTML.call(this, 0, 0)
|
||||
}
|
||||
const html = originalGetSemanticHTML.call(this, sanitizedIndex, sanitizedLength)
|
||||
return html.replace(/ |\u00A0/g, ' ')
|
||||
}
|
||||
Quill.prototype.getSemanticHTML.isPatched = true
|
||||
}
|
||||
}
|
||||
|
||||
// Return reactive state for component binding
|
||||
return reactive({
|
||||
open: false,
|
||||
onInsert: null,
|
||||
onCancel: null
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue