diff --git a/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php b/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php
index 8b40fe4a6..a556e13c5 100644
--- a/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php
+++ b/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php
@@ -26,7 +26,7 @@ function ($attribute, $value, $fail) {
}
},
],
- 'confirmation_reply_to' => 'email|nullable',
+ 'confirmation_reply_to' => 'nullable',
'notification_sender' => 'required',
'notification_subject' => 'required',
'notification_body' => 'required',
@@ -107,6 +107,7 @@ public static function validateEmail($email): bool
public static function formatData(array $data): array
{
return array_merge(parent::formatData($data), [
+ 'notification_subject' => Purify::clean($data['notification_subject'] ?? ''),
'notification_body' => Purify::clean($data['notification_body'] ?? ''),
]);
}
diff --git a/api/app/Mail/Forms/SubmissionConfirmationMail.php b/api/app/Mail/Forms/SubmissionConfirmationMail.php
index b50bb9c1e..ab51190a0 100644
--- a/api/app/Mail/Forms/SubmissionConfirmationMail.php
+++ b/api/app/Mail/Forms/SubmissionConfirmationMail.php
@@ -4,6 +4,7 @@
use App\Events\Forms\FormSubmitted;
use App\Mail\OpenFormMail;
+use App\Open\MentionParser;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -16,6 +17,8 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue
use Queueable;
use SerializesModels;
+ private $formattedData;
+
/**
* Create a new message instance.
*
@@ -23,6 +26,12 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue
*/
public function __construct(private FormSubmitted $event, private $integrationData)
{
+ $formatter = (new FormSubmissionFormatter($event->form, $event->data))
+ ->createLinks()
+ ->outputStringsOnly()
+ ->useSignedUrlForFiles();
+
+ $this->formattedData = $formatter->getFieldsWithValue();
}
/**
@@ -34,17 +43,14 @@ public function build()
{
$form = $this->event->form;
- $formatter = (new FormSubmissionFormatter($form, $this->event->data))
- ->createLinks()
- ->outputStringsOnly()
- ->useSignedUrlForFiles();
return $this
->replyTo($this->getReplyToEmail($form->creator->email))
->from($this->getFromEmail(), $this->integrationData->notification_sender)
- ->subject($this->integrationData->notification_subject)
+ ->subject($this->getSubject())
->markdown('mail.form.confirmation-submission-notification', [
- 'fields' => $formatter->getFieldsWithValue(),
+ 'notificationBody' => $this->getNotificationBody(),
+ 'fields' => $this->formattedData,
'form' => $form,
'integrationData' => $this->integrationData,
'noBranding' => $form->no_branding,
@@ -67,9 +73,25 @@ private function getReplyToEmail($default)
{
$replyTo = $this->integrationData->confirmation_reply_to ?? null;
- if ($replyTo && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
- return $replyTo;
+ if ($replyTo) {
+ $parser = new MentionParser($replyTo, $this->formattedData);
+ $parsedReplyTo = $parser->parse();
+ if ($parsedReplyTo && filter_var($parsedReplyTo, FILTER_VALIDATE_EMAIL)) {
+ return $parsedReplyTo;
+ }
}
return $default;
}
+
+ private function getSubject()
+ {
+ $parser = new MentionParser($this->integrationData->notification_subject, $this->formattedData);
+ return $parser->parse();
+ }
+
+ private function getNotificationBody()
+ {
+ $parser = new MentionParser($this->integrationData->notification_body, $this->formattedData);
+ return $parser->parse();
+ }
}
diff --git a/api/app/Open/MentionParser.php b/api/app/Open/MentionParser.php
new file mode 100644
index 000000000..01c8e3c80
--- /dev/null
+++ b/api/app/Open/MentionParser.php
@@ -0,0 +1,97 @@
+content = $content;
+ $this->data = $data;
+ }
+
+ public function parse()
+ {
+ $doc = new DOMDocument();
+ // Disable libxml errors and use internal errors
+ $internalErrors = libxml_use_internal_errors(true);
+
+ // Wrap the content in a root element to ensure it's valid XML
+ $wrappedContent = '' . $this->content . '';
+
+ // Load HTML, using UTF-8 encoding
+ $doc->loadHTML(mb_convert_encoding($wrappedContent, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
+
+ // Restore libxml error handling
+ libxml_use_internal_errors($internalErrors);
+
+ $xpath = new DOMXPath($doc);
+ $mentionElements = $xpath->query("//span[@mention]");
+
+ foreach ($mentionElements as $element) {
+ $fieldId = $element->getAttribute('mention-field-id');
+ $fallback = $element->getAttribute('mention-fallback');
+ $value = $this->getData($fieldId);
+
+ if ($value !== null) {
+ $textNode = $doc->createTextNode(is_array($value) ? implode(', ', $value) : $value);
+ $element->parentNode->replaceChild($textNode, $element);
+ } elseif ($fallback) {
+ $textNode = $doc->createTextNode($fallback);
+ $element->parentNode->replaceChild($textNode, $element);
+ } else {
+ $element->parentNode->removeChild($element);
+ }
+ }
+
+ // Extract and return the processed HTML content
+ $result = $doc->saveHTML($doc->getElementsByTagName('root')->item(0));
+
+ // Remove the root tags we added
+ $result = preg_replace('/<\/?root>/', '', $result);
+
+ // Trim whitespace and convert HTML entities back to UTF-8 characters
+ $result = trim(html_entity_decode($result, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+
+ return $result;
+ }
+
+ private function replaceMentions()
+ {
+ $pattern = '/]*mention-field-id="([^"]*)"[^>]*mention-fallback="([^"]*)"[^>]*>.*?<\/span>/';
+ return preg_replace_callback($pattern, function ($matches) {
+ $fieldId = $matches[1];
+ $fallback = $matches[2];
+ $value = $this->getData($fieldId);
+
+ if ($value !== null) {
+ if (is_array($value)) {
+ return implode(' ', array_map(function ($v) {
+ return $v;
+ }, $value));
+ }
+ return $value;
+ } elseif ($fallback) {
+ return $fallback;
+ }
+ return '';
+ }, $this->content);
+ }
+
+ private function getData($fieldId)
+ {
+ $value = collect($this->data)->firstWhere('id', $fieldId)['value'] ?? null;
+
+ if (is_object($value)) {
+ return (array) $value;
+ }
+
+ return $value;
+ }
+}
diff --git a/api/app/Service/HtmlPurifier/OpenFormsHtmlDefinition.php b/api/app/Service/HtmlPurifier/OpenFormsHtmlDefinition.php
new file mode 100644
index 000000000..ba9fb807f
--- /dev/null
+++ b/api/app/Service/HtmlPurifier/OpenFormsHtmlDefinition.php
@@ -0,0 +1,21 @@
+addAttribute('span', 'mention-field-id', 'Text');
+ $definition->addAttribute('span', 'mention-field-name', 'Text');
+ $definition->addAttribute('span', 'mention-fallback', 'Text');
+ $definition->addAttribute('span', 'mention', 'Bool');
+ $definition->addAttribute('span', 'contenteditable', 'Bool');
+ }
+}
diff --git a/api/config/purify.php b/api/config/purify.php
index 24c8d1ce4..f440c058c 100644
--- a/api/config/purify.php
+++ b/api/config/purify.php
@@ -1,6 +1,6 @@
[
'default' => [
- 'HTML.Allowed' => 'h1,h2,b,strong,i,em,a[href|title],ul,ol,li,p,br,span,*[style]',
+ 'HTML.Allowed' => 'h1,h2,b,strong,i,em,a[href|title],ul,ol,li,p,br,span[mention|mention-field-id|mention-field-name|mention-fallback],*[style]',
'HTML.ForbiddenElements' => '',
'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,text-decoration,color,text-align',
@@ -86,7 +86,7 @@
|
*/
- 'definitions' => Html5Definition::class,
+ 'definitions' => OpenFormsHtmlDefinition::class,
/*
|--------------------------------------------------------------------------
diff --git a/api/resources/views/mail/form/confirmation-submission-notification.blade.php b/api/resources/views/mail/form/confirmation-submission-notification.blade.php
index 871998f30..271779fc0 100644
--- a/api/resources/views/mail/form/confirmation-submission-notification.blade.php
+++ b/api/resources/views/mail/form/confirmation-submission-notification.blade.php
@@ -1,6 +1,6 @@
@component('mail::message', ['noBranding' => $noBranding])
-{!! $integrationData->notification_body !!}
+{!! $notificationBody !!}
@if($form->editable_submissions)
@component('mail::button', ['url' => $form->share_url.'?submission_id='.$submission_id])
diff --git a/api/tests/Unit/Service/Forms/MentionParserTest.php b/api/tests/Unit/Service/Forms/MentionParserTest.php
new file mode 100644
index 000000000..908f0bc1b
--- /dev/null
+++ b/api/tests/Unit/Service/Forms/MentionParserTest.php
@@ -0,0 +1,193 @@
+Hello Placeholder
';
+
+ $data = [['nf_id' => '123', 'value' => 'World']];
+
+
+
+
+ $parser = new MentionParser($content, $data);
+
+ $result = $parser->parse();
+
+
+
+
+ expect($result)->toBe('Hello World
');
+
+});
+
+
+
+
+test('it handles multiple mentions', function () {
+
+ $content = 'Name is Age years old
';
+
+ $data = [
+
+ ['nf_id' => '123', 'value' => 'John'],
+
+ ['nf_id' => '456', 'value' => 30],
+
+ ];
+
+
+
+
+ $parser = new MentionParser($content, $data);
+
+ $result = $parser->parse();
+
+
+
+
+ expect($result)->toBe('John is 30 years old
');
+
+});
+
+
+
+
+test('it uses fallback when value is not found', function () {
+
+ $content = 'Hello Placeholder
';
+
+ $data = [];
+
+
+
+
+ $parser = new MentionParser($content, $data);
+
+ $result = $parser->parse();
+
+
+
+
+ expect($result)->toBe('Hello Friend
');
+
+});
+
+
+
+
+test('it removes mention element when no value and no fallback', function () {
+
+ $content = 'Hello Placeholder
';
+
+ $data = [];
+
+
+
+
+ $parser = new MentionParser($content, $data);
+
+ $result = $parser->parse();
+
+
+
+
+ expect($result)->toBe('Hello
');
+
+});
+
+
+
+
+test('it handles array values', function () {
+
+ $content = 'Tags: Placeholder
';
+
+ $data = [['nf_id' => '123', 'value' => ['PHP', 'Laravel', 'Testing']]];
+
+
+
+
+ $parser = new MentionParser($content, $data);
+
+ $result = $parser->parse();
+
+
+
+
+ expect($result)->toBe('Tags: PHP, Laravel, Testing
');
+
+});
+
+
+
+
+test('it preserves HTML structure', function () {
+
+ $content = 'Hello Placeholder
How are you?
';
+
+ $data = [['nf_id' => '123', 'value' => 'World']];
+
+
+
+
+ $parser = new MentionParser($content, $data);
+
+ $result = $parser->parse();
+
+
+
+
+ expect($result)->toBe('');
+
+});
+
+
+
+
+test('it handles UTF-8 characters', function () {
+
+ $content = 'こんにちは Placeholder
';
+
+ $data = [['nf_id' => '123', 'value' => '世界']];
+
+
+
+
+ $parser = new MentionParser($content, $data);
+
+ $result = $parser->parse();
+
+
+
+
+ expect($result)->toBe('こんにちは 世界
');
+
+});
+
+
+
+
+test('it handles content without surrounding paragraph tags', function () {
+
+ $content = 'some text Post excerpt dewde';
+
+ $data = [['nf_id' => '123', 'value' => 'replaced text']];
+
+
+
+
+ $parser = new MentionParser($content, $data);
+
+ $result = $parser->parse();
+
+
+
+
+ expect($result)->toBe('some text replaced text dewde');
+
+});
diff --git a/client/components/forms/MentionInput.vue b/client/components/forms/MentionInput.vue
new file mode 100644
index 000000000..3dbb8369f
--- /dev/null
+++ b/client/components/forms/MentionInput.vue
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/forms/RichTextAreaInput.client.vue b/client/components/forms/RichTextAreaInput.client.vue
index 26143045c..4f398b6f4 100644
--- a/client/components/forms/RichTextAreaInput.client.vue
+++ b/client/components/forms/RichTextAreaInput.client.vue
@@ -4,12 +4,8 @@
-
+ :style="{
+ '--font-size': theme.default.fontSize
+ }"
+ >
+
+
@@ -30,59 +37,73 @@
+
+
+
-
+.ql-mention {
+ padding-top: 0px !important;
+}
+.ql-mention::after {
+ content: '@';
+ font-size: 16px;
+}
+.rich-editor, .mention-input {
+ span[mention] {
+ @apply inline-flex items-center align-baseline leading-tight text-sm relative bg-blue-100 text-blue-800 border border-blue-200 rounded-md px-1 py-0.5 mx-0.5;
+ max-width: 200px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
\ No newline at end of file
diff --git a/client/components/forms/TextBlock.vue b/client/components/forms/TextBlock.vue
new file mode 100644
index 000000000..8a2c7c641
--- /dev/null
+++ b/client/components/forms/TextBlock.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/forms/components/FormSubmissionFormatter.js b/client/components/forms/components/FormSubmissionFormatter.js
new file mode 100644
index 000000000..ea7cab15e
--- /dev/null
+++ b/client/components/forms/components/FormSubmissionFormatter.js
@@ -0,0 +1,105 @@
+import { format, parseISO } from 'date-fns'
+
+export class FormSubmissionFormatter {
+ constructor(form, formData) {
+ this.form = form
+ this.formData = formData
+ this.createLinks = false
+ this.outputStringsOnly = false
+ this.showGeneratedIds = false
+ this.datesIsoFormat = false
+ }
+
+ setCreateLinks() {
+ this.createLinks = true
+ return this
+ }
+
+ setShowGeneratedIds() {
+ this.showGeneratedIds = true
+ return this
+ }
+
+ setOutputStringsOnly() {
+ this.outputStringsOnly = true
+ return this
+ }
+
+ setUseIsoFormatForDates() {
+ this.datesIsoFormat = true
+ return this
+ }
+
+ getFormattedData() {
+ const formattedData = {}
+
+ this.form.properties.forEach(field => {
+ if (!this.formData[field.id] && !this.fieldGeneratesId(field)) {
+ return
+ }
+
+ const value = this.formatFieldValue(field, this.formData[field.id])
+ formattedData[field.id] = value
+ })
+
+ return formattedData
+ }
+
+ formatFieldValue(field, value) {
+ switch (field.type) {
+ case 'url':
+ return this.createLinks ? `${value}` : value
+ case 'email':
+ return this.createLinks ? `${value}` : value
+ case 'checkbox':
+ return value ? 'Yes' : 'No'
+ case 'date':
+ return this.formatDateValue(field, value)
+ case 'people':
+ return this.formatPeopleValue(value)
+ case 'multi_select':
+ return this.outputStringsOnly ? value.join(', ') : value
+ case 'relation':
+ return this.formatRelationValue(value)
+ default:
+ return Array.isArray(value) && this.outputStringsOnly ? value.join(', ') : value
+ }
+ }
+
+ formatDateValue(field, value) {
+ if (this.datesIsoFormat) {
+ return Array.isArray(value)
+ ? { start_date: value[0], end_date: value[1] || null }
+ : value
+ }
+
+ const dateFormat = (field.date_format || 'dd/MM/yyyy') === 'dd/MM/yyyy' ? 'dd/MM/yyyy' : 'MM/dd/yyyy'
+ const timeFormat = field.with_time ? (field.time_format === 24 ? 'HH:mm' : 'h:mm a') : ''
+ const fullFormat = `${dateFormat}${timeFormat ? ' ' + timeFormat : ''}`
+
+ if (Array.isArray(value)) {
+ const start = format(parseISO(value[0]), fullFormat)
+ const end = value[1] ? format(parseISO(value[1]), fullFormat) : null
+ return end ? `${start} - ${end}` : start
+ }
+
+ return format(parseISO(value), fullFormat)
+ }
+
+ formatPeopleValue(value) {
+ if (!value) return []
+ const people = Array.isArray(value) ? value : [value]
+ return this.outputStringsOnly ? people.map(p => p.name).join(', ') : people
+ }
+
+ formatRelationValue(value) {
+ if (!value) return []
+ const relations = Array.isArray(value) ? value : [value]
+ const formatted = relations.map(r => r.title || 'Untitled')
+ return this.outputStringsOnly ? formatted.join(', ') : formatted
+ }
+
+ fieldGeneratesId(field) {
+ return this.showGeneratedIds && (field.generates_auto_increment_id || field.generates_uuid)
+ }
+}
diff --git a/client/components/forms/components/MentionDropdown.vue b/client/components/forms/components/MentionDropdown.vue
new file mode 100644
index 000000000..1e7b116cc
--- /dev/null
+++ b/client/components/forms/components/MentionDropdown.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+ Insert
+
+
+ Cancel
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/forms/components/QuillyEditor.vue b/client/components/forms/components/QuillyEditor.vue
new file mode 100644
index 000000000..854cb714c
--- /dev/null
+++ b/client/components/forms/components/QuillyEditor.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue
index e61ce3fe7..48598de3c 100644
--- a/client/components/open/forms/OpenCompleteForm.vue
+++ b/client/components/open/forms/OpenCompleteForm.vue
@@ -140,9 +140,13 @@
key="submitted"
class="px-2"
>
-
{
+ this.submittedData = form.data()
useAmplitude().logEvent('form_submission', {
workspace_id: this.form.workspace_id,
form_id: this.form.id
diff --git a/client/components/open/forms/components/FirstSubmissionModal.vue b/client/components/open/forms/components/FirstSubmissionModal.vue
index 25fbbf990..333990ee9 100644
--- a/client/components/open/forms/components/FirstSubmissionModal.vue
+++ b/client/components/open/forms/components/FirstSubmissionModal.vue
@@ -2,7 +2,7 @@
diff --git a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue
index 1a5214388..41625e1d7 100644
--- a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue
+++ b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue
@@ -129,6 +129,8 @@
-
-
!Form.ignore.includes(key))
.forEach((key) => {
- this[key] = JSON.parse(JSON.stringify(this.originalData[key]))
+ this[key] = cloneDeep(this.originalData[key])
})
}
diff --git a/client/composables/useParseMention.js b/client/composables/useParseMention.js
new file mode 100644
index 000000000..59494d307
--- /dev/null
+++ b/client/composables/useParseMention.js
@@ -0,0 +1,39 @@
+import { FormSubmissionFormatter } from '~/components/forms/components/FormSubmissionFormatter'
+
+export function useParseMention(content, mentionsAllowed, form, formData) {
+ if (!mentionsAllowed || !form || !formData) {
+ return content
+ }
+
+ const formatter = new FormSubmissionFormatter(form, formData).setOutputStringsOnly()
+ const formattedData = formatter.getFormattedData()
+
+ // Create a new DOMParser
+ const parser = new DOMParser()
+ // Parse the content as HTML
+ const doc = parser.parseFromString(content, 'text/html')
+
+ // Find all elements with mention attribute
+ const mentionElements = doc.querySelectorAll('[mention]')
+
+ mentionElements.forEach(element => {
+ const fieldId = element.getAttribute('mention-field-id')
+ const fallback = element.getAttribute('mention-fallback')
+ const value = formattedData[fieldId]
+
+ if (value) {
+ if (Array.isArray(value)) {
+ element.textContent = value.join(', ')
+ } else {
+ element.textContent = value
+ }
+ } else if (fallback) {
+ element.textContent = fallback
+ } else {
+ element.remove()
+ }
+ })
+
+ // Return the processed HTML content
+ return doc.body.innerHTML
+}
\ No newline at end of file
diff --git a/client/data/blocks_types.json b/client/data/blocks_types.json
index c13950505..0a1ac7b9b 100644
--- a/client/data/blocks_types.json
+++ b/client/data/blocks_types.json
@@ -5,7 +5,8 @@
"icon": "i-heroicons-bars-3-bottom-left",
"default_block_name": "Your name",
"bg_class": "bg-blue-100",
- "text_class": "text-blue-900"
+ "text_class": "text-blue-900",
+ "is_input": true
},
"date": {
"name": "date",
@@ -13,7 +14,8 @@
"icon": "i-heroicons-calendar-20-solid",
"default_block_name": "Date",
"bg_class": "bg-green-100",
- "text_class": "text-green-900"
+ "text_class": "text-green-900",
+ "is_input": true
},
"url": {
"name": "url",
@@ -21,7 +23,8 @@
"icon": "i-heroicons-link-20-solid",
"default_block_name": "Link",
"bg_class": "bg-blue-100",
- "text_class": "text-blue-900"
+ "text_class": "text-blue-900",
+ "is_input": true
},
"phone_number": {
"name": "phone_number",
@@ -29,7 +32,8 @@
"icon": "i-heroicons-phone-20-solid",
"default_block_name": "Phone Number",
"bg_class": "bg-blue-100",
- "text_class": "text-blue-900"
+ "text_class": "text-blue-900",
+ "is_input": true
},
"email": {
"name": "email",
@@ -37,7 +41,8 @@
"icon": "i-heroicons-at-symbol-20-solid",
"default_block_name": "Email",
"bg_class": "bg-blue-100",
- "text_class": "text-blue-900"
+ "text_class": "text-blue-900",
+ "is_input": true
},
"checkbox": {
"name": "checkbox",
@@ -45,7 +50,8 @@
"icon": "i-heroicons-check-circle",
"default_block_name": "Checkbox",
"bg_class": "bg-red-100",
- "text_class": "text-red-900"
+ "text_class": "text-red-900",
+ "is_input": true
},
"select": {
"name": "select",
@@ -53,7 +59,8 @@
"icon": "i-heroicons-chevron-up-down-20-solid",
"default_block_name": "Select",
"bg_class": "bg-red-100",
- "text_class": "text-red-900"
+ "text_class": "text-red-900",
+ "is_input": true
},
"multi_select": {
"name": "multi_select",
@@ -61,7 +68,8 @@
"icon": "i-heroicons-chevron-up-down-20-solid",
"default_block_name": "Multi Select",
"bg_class": "bg-red-100",
- "text_class": "text-red-900"
+ "text_class": "text-red-900",
+ "is_input": true
},
"matrix": {
"name": "matrix",
@@ -69,7 +77,8 @@
"icon": "i-heroicons-table-cells-20-solid",
"default_block_name": "Matrix",
"bg_class": "bg-red-100",
- "text_class": "text-red-900"
+ "text_class": "text-red-900",
+ "is_input": true
},
"number": {
"name": "number",
@@ -77,7 +86,8 @@
"icon": "i-heroicons-hashtag-20-solid",
"default_block_name": "Number",
"bg_class": "bg-purple-100",
- "text_class": "text-purple-900"
+ "text_class": "text-purple-900",
+ "is_input": true
},
"rating": {
"name": "rating",
@@ -85,7 +95,8 @@
"icon": "i-heroicons-star",
"default_block_name": "Rating",
"bg_class": "bg-purple-100",
- "text_class": "text-purple-900"
+ "text_class": "text-purple-900",
+ "is_input": true
},
"scale": {
"name": "scale",
@@ -93,7 +104,8 @@
"icon": "i-heroicons-scale-20-solid",
"default_block_name": "Scale",
"bg_class": "bg-purple-100",
- "text_class": "text-purple-900"
+ "text_class": "text-purple-900",
+ "is_input": true
},
"slider": {
"name": "slider",
@@ -101,7 +113,8 @@
"icon": "i-heroicons-adjustments-horizontal",
"default_block_name": "Slider",
"bg_class": "bg-purple-100",
- "text_class": "text-purple-900"
+ "text_class": "text-purple-900",
+ "is_input": true
},
"files": {
"name": "files",
@@ -109,7 +122,8 @@
"icon": "i-heroicons-paper-clip",
"default_block_name": "Files",
"bg_class": "bg-pink-100",
- "text_class": "text-pink-900"
+ "text_class": "text-pink-900",
+ "is_input": true
},
"signature": {
"name": "signature",
@@ -117,7 +131,8 @@
"icon": "i-heroicons-pencil-square-20-solid",
"default_block_name": "Signature",
"bg_class": "bg-pink-100",
- "text_class": "text-pink-900"
+ "text_class": "text-pink-900",
+ "is_input": true
},
"nf-text": {
"name": "nf-text",
@@ -125,7 +140,8 @@
"icon": "i-heroicons-bars-3",
"default_block_name": "Text",
"bg_class": "bg-yellow-100",
- "text_class": "text-yellow-900"
+ "text_class": "text-yellow-900",
+ "is_input": false
},
"nf-page-break": {
"name": "nf-page-break",
@@ -133,7 +149,8 @@
"icon": "i-heroicons-document-plus",
"default_block_name": "Page Break",
"bg_class": "bg-gray-100",
- "text_class": "text-gray-900"
+ "text_class": "text-gray-900",
+ "is_input": false
},
"nf-divider": {
"name": "nf-divider",
@@ -141,7 +158,8 @@
"icon": "i-heroicons-minus",
"default_block_name": "Divider",
"bg_class": "bg-gray-100",
- "text_class": "text-gray-900"
+ "text_class": "text-gray-900",
+ "is_input": false
},
"nf-image": {
"name": "nf-image",
@@ -149,7 +167,8 @@
"icon": "i-heroicons-photo",
"default_block_name": "Image",
"bg_class": "bg-yellow-100",
- "text_class": "text-yellow-900"
+ "text_class": "text-yellow-900",
+ "is_input": false
},
"nf-code": {
"name": "nf-code",
@@ -157,6 +176,7 @@
"icon": "i-heroicons-code-bracket",
"default_block_name": "Code Block",
"bg_class": "bg-yellow-100",
- "text_class": "text-yellow-900"
+ "text_class": "text-yellow-900",
+ "is_input": false
}
}
diff --git a/client/lib/quill/quillMentionExtension.js b/client/lib/quill/quillMentionExtension.js
new file mode 100644
index 000000000..ffb28d8d6
--- /dev/null
+++ b/client/lib/quill/quillMentionExtension.js
@@ -0,0 +1,130 @@
+import { reactive } from 'vue'
+import Quill from 'quill'
+const Inline = Quill.import('blots/inline')
+
+export default function registerMentionExtension(Quill) {
+ class MentionBlot extends Inline {
+ static blotName = 'mention'
+ static tagName = 'SPAN'
+
+ static create(data) {
+ let node = super.create()
+ MentionBlot.setAttributes(node, data)
+ return node
+ }
+
+ static setAttributes(node, data) {
+ node.setAttribute('contenteditable', 'false')
+ node.setAttribute('mention', 'true')
+
+ if (data && typeof data === 'object') {
+ node.setAttribute('mention-field-id', data.field?.nf_id || '')
+ node.setAttribute('mention-field-name', data.field?.name || '')
+ node.setAttribute('mention-fallback', data.fallback || '')
+ node.textContent = data.field?.name || ''
+ } else {
+ // Handle case where data is not an object (e.g., during undo)
+ node.textContent = data || ''
+ }
+ }
+
+ 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') || ''
+ }
+ }
+
+ 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.formats(this.domNode)
+ return formats
+ }
+
+ static value(domNode) {
+ return {
+ field: {
+ nf_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)
+
+ const mentionState = reactive({
+ open: false,
+ onInsert: null,
+ onCancel: null,
+ })
+
+ class MentionModule {
+ constructor(quill, options) {
+ this.quill = quill
+ this.options = options
+
+ this.setupMentions()
+ }
+
+ setupMentions() {
+ const toolbar = this.quill.getModule('toolbar')
+ if (toolbar) {
+ toolbar.addHandler('mention', () => {
+ const range = this.quill.getSelection()
+ if (range) {
+ mentionState.open = true
+ mentionState.onInsert = (mention) => {
+ this.insertMention(mention, range.index)
+ }
+ mentionState.onCancel = () => {
+ mentionState.open = false
+ }
+ }
+ })
+ }
+ }
+
+ insertMention(mention, index) {
+ mentionState.open = false
+
+ // Insert the mention
+ this.quill.insertEmbed(index, 'mention', mention, Quill.sources.USER)
+
+ // Calculate the length of the inserted mention
+ const mentionLength = this.quill.getLength() - index
+
+ nextTick(() => {
+ // Focus the editor
+ this.quill.focus()
+
+ // Set the selection after the mention
+ this.quill.setSelection(index + mentionLength, 0, Quill.sources.SILENT)
+ })
+ }
+ }
+
+ Quill.register('modules/mention', MentionModule)
+
+ return mentionState
+}
\ No newline at end of file
diff --git a/client/package.json b/client/package.json
index d5ca746c2..ba1c30512 100644
--- a/client/package.json
+++ b/client/package.json
@@ -59,6 +59,7 @@
"prismjs": "^1.24.1",
"qrcode": "^1.5.1",
"query-builder-vue-3": "^1.0.1",
+ "quill": "^2.0.2",
"tailwind-merge": "^2.3.0",
"tinymotion": "^0.2.0",
"v-calendar": "^3.1.2",
@@ -70,7 +71,6 @@
"vue-json-pretty": "^2.4.0",
"vue-notion": "^3.0.0-beta.1",
"vue-signature-pad": "^3.0.2",
- "vue3-editor": "^0.1.1",
"vuedraggable": "next",
"webcam-easy": "^1.1.1"
},