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:
@@ -3,12 +3,12 @@
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
|
||||
<MentionDropdown
|
||||
:state="mentionState"
|
||||
:mention-state="mentionState"
|
||||
:mentions="mentions"
|
||||
/>
|
||||
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
ref="editableDiv"
|
||||
@@ -38,14 +38,14 @@
|
||||
@click="openMentionDropdown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<template
|
||||
v-if="$slots.help"
|
||||
#help
|
||||
>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
|
||||
|
||||
<template
|
||||
v-if="$slots.error"
|
||||
#error
|
||||
@@ -54,146 +54,169 @@
|
||||
</template>
|
||||
</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({
|
||||
...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({
|
||||
open: false,
|
||||
onInsert: (mention) => {
|
||||
insertMention(mention)
|
||||
mentionState.value.open = false
|
||||
},
|
||||
onCancel: () => {
|
||||
mentionState.value.open = false
|
||||
restoreSelection()
|
||||
},
|
||||
})
|
||||
const createMentionSpan = (mention) => {
|
||||
const mentionSpan = document.createElement('span')
|
||||
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.textContent = mention.field.name.length > 25 ? `${mention.field.name.slice(0, 25)}...` : mention.field.name
|
||||
return mentionSpan
|
||||
}
|
||||
const insertMention = (mention) => {
|
||||
const mentionSpan = createMentionSpan(mention)
|
||||
<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()
|
||||
|
||||
// Create a reactive state object for the mention dropdown
|
||||
const mentionState = reactive({
|
||||
open: false,
|
||||
onInsert: (mention) => {
|
||||
insertMention(mention)
|
||||
mentionState.open = false
|
||||
},
|
||||
onCancel: () => {
|
||||
mentionState.open = false
|
||||
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 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('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()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
// Ensure the editableDiv is focused
|
||||
editableDiv.value.focus()
|
||||
updateCompVal()
|
||||
savedRange.value = range
|
||||
}
|
||||
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()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
savedRange.value = range
|
||||
}
|
||||
mentionState.value.open = true
|
||||
mentionState.open = true
|
||||
}
|
||||
|
||||
const saveSelection = () => {
|
||||
const selection = window.getSelection()
|
||||
if (selection.rangeCount > 0) {
|
||||
savedRange.value = selection.getRangeAt(0)
|
||||
}
|
||||
const saveSelection = () => {
|
||||
}
|
||||
|
||||
const restoreSelection = () => {
|
||||
if (savedRange.value) {
|
||||
const selection = window.getSelection()
|
||||
if (selection.rangeCount > 0) {
|
||||
savedRange.value = selection.getRangeAt(0)
|
||||
}
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(savedRange.value)
|
||||
editableDiv.value.focus()
|
||||
}
|
||||
const restoreSelection = () => {
|
||||
if (savedRange.value) {
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(savedRange.value)
|
||||
editableDiv.value.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const updateCompVal = () => {
|
||||
compVal.value = editableDiv.value.innerHTML
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
onMounted(() => {
|
||||
if (compVal.value) {
|
||||
editableDiv.value.innerHTML = compVal.value
|
||||
}
|
||||
})
|
||||
watch(compVal, (newVal) => {
|
||||
if (editableDiv.value && editableDiv.value.innerHTML !== newVal) {
|
||||
editableDiv.value.innerHTML = newVal
|
||||
}
|
||||
})
|
||||
defineExpose({
|
||||
editableDiv,
|
||||
compVal,
|
||||
mentionState,
|
||||
openMentionDropdown,
|
||||
onInput,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mention-input {
|
||||
min-height: 1.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.mention-input:empty::before {
|
||||
content: attr(placeholder);
|
||||
color: #9ca3af;
|
||||
}
|
||||
.mention-input span[mention] {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
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>
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
editableDiv,
|
||||
compVal,
|
||||
mentionState,
|
||||
openMentionDropdown,
|
||||
onInput,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mention-input {
|
||||
min-height: 1.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mention-input:empty::before {
|
||||
content: attr(placeholder);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.mention-input span[mention] {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
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"
|
||||
: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"
|
||||
>
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex border-t pt-2 -mx-2 px-2 justify-end space-x-2">
|
||||
<UButton
|
||||
size="sm"
|
||||
@@ -66,51 +66,62 @@
|
||||
</template>
|
||||
</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
|
||||
}
|
||||
})
|
||||
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()
|
||||
}
|
||||
|
||||
<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
|
||||
}
|
||||
watch(open, (newValue) => {
|
||||
if (newValue) {
|
||||
selectedField.value = null
|
||||
fallbackValue.value = ''
|
||||
}
|
||||
})
|
||||
const insertMention = () => {
|
||||
if (selectedField.value && onInsert.value) {
|
||||
onInsert.value({
|
||||
field: selectedField.value,
|
||||
fallback: fallbackValue.value
|
||||
})
|
||||
open.value = false
|
||||
}
|
||||
})
|
||||
|
||||
defineShortcuts({
|
||||
escape: () => {
|
||||
props.mentionState.open = false
|
||||
}
|
||||
const cancel = () => {
|
||||
if (onCancel.value) {
|
||||
onCancel.value()
|
||||
}
|
||||
open.value = 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) {
|
||||
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 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) {
|
||||
|
||||
Reference in New Issue
Block a user