From 6386eec5e7683f64ea6c944f8738d101612c04cd Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 5 May 2026 12:55:53 +0530 Subject: [PATCH 1/3] fix: regex validation not applied for custom text attributes in UI (#14110) # Pull Request Template ## Description This PR fixes multiple issues related to regex patterns and validation for custom attributes. 1. Fixed regex patterns being double-escaped when saving from Add and Edit flows 2. Fixed regex validation not being enforced in the widget pre-chat form 3. Minor UI improvements in the Add/Edit custom attribute dialog Fixes [CW-6625](https://linear.app/chatwoot/issue/CW-6625/bug-report-custom-attribute-regex-validation-not-working-in-ui), https://github.com/chatwoot/chatwoot/issues/13771 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? **Loom video** **Before** https://www.loom.com/share/14f1983a8bc84f9fabc3663afd83cd50 **After** https://www.loom.com/share/867c0484741140c1944fcbd43914c9c0 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../CustomAttributes/OtherAttribute.vue | 6 +- .../dashboard/components/CustomAttribute.vue | 20 +++--- .../settings/attributes/AddAttribute.vue | 22 ++---- .../settings/attributes/EditAttribute.vue | 21 ++---- app/javascript/shared/helpers/Validators.js | 16 +++++ .../helpers/specs/ValidatorsHelper.spec.js | 25 +++++++ .../widget/components/PreChat/Form.vue | 72 ++++++++++--------- 7 files changed, 109 insertions(+), 73 deletions(-) diff --git a/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue b/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue index 7f0379b9a..913e3e381 100644 --- a/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue +++ b/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue @@ -49,7 +49,11 @@ const rules = computed(() => ({ props.attribute.regexPattern && { regexValidation: value => { if (!value) return true; - return getRegexp(props.attribute.regexPattern).test(value); + try { + return getRegexp(props.attribute.regexPattern).test(value); + } catch { + return false; + } }, }), }, diff --git a/app/javascript/dashboard/components/CustomAttribute.vue b/app/javascript/dashboard/components/CustomAttribute.vue index 8f70a9fa2..232cc9aac 100644 --- a/app/javascript/dashboard/components/CustomAttribute.vue +++ b/app/javascript/dashboard/components/CustomAttribute.vue @@ -102,13 +102,14 @@ export default { return this.v$.editedValue.$error; }, errorMessage() { - if (this.v$.editedValue.url) { + if (this.v$.editedValue.url?.$invalid) { return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL'); } - if (!this.v$.editedValue.regexValidation) { - return this.regexCue - ? this.regexCue - : this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT'); + if (this.v$.editedValue.regexValidation?.$invalid) { + return ( + this.regexCue || + this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT') + ); } return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED'); }, @@ -134,9 +135,12 @@ export default { editedValue: { required, regexValidation: value => { - return !( - this.attributeRegex && !getRegexp(this.attributeRegex).test(value) - ); + if (!this.attributeRegex || !value) return true; + try { + return getRegexp(this.attributeRegex).test(value); + } catch { + return false; + } }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue index 9b097f6a1..8d85d56af 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue @@ -4,6 +4,7 @@ import { required, minLength } from '@vuelidate/validators'; import { mapGetters } from 'vuex'; import { useAlert } from 'dashboard/composables'; import { convertToAttributeSlug } from 'dashboard/helper/commons.js'; +import { normalizeRegexPattern } from 'shared/helpers/Validators'; import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants'; import NextButton from 'dashboard/components-next/button/Button.vue'; @@ -99,19 +100,10 @@ export default { }, validations: { - displayName: { - required, - minLength: minLength(1), - }, - description: { - required, - }, - attributeModel: { - required, - }, - attributeType: { - required, - }, + displayName: { required, minLength: minLength(1) }, + description: { required }, + attributeModel: { required }, + attributeType: { required }, attributeKey: { required, isKey(value) { @@ -151,9 +143,7 @@ export default { attribute_display_type: this.attributeType, attribute_key: this.attributeKey, attribute_values: this.attributeListValues, - regex_pattern: this.regexPattern - ? new RegExp(this.regexPattern).toString() - : null, + regex_pattern: normalizeRegexPattern(this.regexPattern), regex_cue: this.regexCue, }); this.alertMessage = this.$t('ATTRIBUTES_MGMT.ADD.API.SUCCESS_MESSAGE'); diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue index 35cba6a86..308ea35c7 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue @@ -2,7 +2,7 @@ import { useVuelidate } from '@vuelidate/core'; import { useAlert } from 'dashboard/composables'; import { required, minLength } from '@vuelidate/validators'; -import { getRegexp } from 'shared/helpers/Validators'; +import { getRegexp, normalizeRegexPattern } from 'shared/helpers/Validators'; import { ATTRIBUTE_TYPES } from './constants'; import NextButton from 'dashboard/components-next/button/Button.vue'; import TagInput from 'dashboard/components-next/taginput/TagInput.vue'; @@ -41,16 +41,9 @@ export default { }; }, validations: { - displayName: { - required, - }, - attributeType: { - required, - }, - description: { - required, - minLength: minLength(1), - }, + displayName: { required }, + attributeType: { required }, + description: { required, minLength: minLength(1) }, attributeKey: { required, isKey(value) { @@ -118,7 +111,7 @@ export default { }, setFormValues() { const regexPattern = this.selectedAttribute.regex_pattern - ? getRegexp(this.selectedAttribute.regex_pattern).source + ? getRegexp(this.selectedAttribute.regex_pattern).toString() : null; this.displayName = this.selectedAttribute.attribute_display_name; this.description = this.selectedAttribute.attribute_description; @@ -144,9 +137,7 @@ export default { attribute_description: this.description, attribute_display_name: this.displayName, attribute_values: this.updatedAttributeListValues, - regex_pattern: this.regexPattern - ? new RegExp(this.regexPattern).toString() - : null, + regex_pattern: normalizeRegexPattern(this.regexPattern), regex_cue: this.regexCue, }); this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE'); diff --git a/app/javascript/shared/helpers/Validators.js b/app/javascript/shared/helpers/Validators.js index 1c10121f1..a3547a5ca 100644 --- a/app/javascript/shared/helpers/Validators.js +++ b/app/javascript/shared/helpers/Validators.js @@ -101,6 +101,22 @@ export const getRegexp = regexPatternValue => { ); }; +/** + * Normalises a user-entered regex pattern into canonical `/source/flags` form. + * Strips `/.../flags` wrapping if the user included it, so `new RegExp` does + * not double-escape the slashes on save. + * + * @param {string} pattern - Raw pattern string from the form. + * @returns {?string} Canonical `/source/flags` string, or null when empty. + */ +export const normalizeRegexPattern = pattern => { + if (!pattern) return null; + const match = pattern.match(/^\/(.+)\/([gimsuy]*)$/); + const source = match ? match[1] : pattern; + const flags = match ? match[2] : ''; + return new RegExp(source, flags).toString(); +}; + /** * Checks if a string is a valid slug (letters, numbers, hyphens only, no spaces or other symbols). * @param {string} value - The slug to validate. diff --git a/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js index 4546b43a8..62c8f8bee 100644 --- a/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js +++ b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js @@ -9,6 +9,7 @@ import { isNumber, isDomain, getRegexp, + normalizeRegexPattern, isValidSlug, } from '../Validators'; @@ -155,6 +156,30 @@ describe('#getRegexp', () => { }); }); +describe('#normalizeRegexPattern', () => { + it('returns null for empty values', () => { + expect(normalizeRegexPattern('')).toBeNull(); + expect(normalizeRegexPattern(null)).toBeNull(); + expect(normalizeRegexPattern(undefined)).toBeNull(); + }); + + it('canonicalises a bare source', () => { + expect(normalizeRegexPattern('^[0-9]+$')).toBe('/^[0-9]+$/'); + }); + + it('strips slash wrapping the user may include', () => { + expect(normalizeRegexPattern('/^[0-9]+$/')).toBe('/^[0-9]+$/'); + }); + + it('preserves flags on wrapped input', () => { + expect(normalizeRegexPattern('/hello/gi')).toBe('/hello/gi'); + }); + + it('throws for an invalid regex source', () => { + expect(() => normalizeRegexPattern('[')).toThrow(); + }); +}); + describe('#isValidSlug', () => { it('should return true for valid slugs', () => { expect(isValidSlug('abc')).toEqual(true); diff --git a/app/javascript/widget/components/PreChat/Form.vue b/app/javascript/widget/components/PreChat/Form.vue index 2868d6c3c..280f08363 100644 --- a/app/javascript/widget/components/PreChat/Form.vue +++ b/app/javascript/widget/components/PreChat/Form.vue @@ -140,24 +140,15 @@ export default { }, }, methods: { - labelClass(input) { - const { state } = input.context; - const hasErrors = state.invalid; - return !hasErrors ? 'text-n-slate-12' : 'text-n-ruby-10'; - }, inputClass(input) { const { state, family: classification, type } = input.context; - const hasErrors = state.invalid; if (classification === 'box' && type === 'checkbox') { return ''; } if (type === 'phoneInput') { - this.hasErrorInPhoneInput = hasErrors; + this.hasErrorInPhoneInput = state.invalid; } - if (!hasErrors) { - return `mt-1 rounded w-full py-2 px-3`; - } - return `mt-1 rounded w-full py-2 px-3 error`; + return 'mt-1 rounded w-full py-2 px-3'; }, isContactFieldRequired(field) { return this.preChatFields.find(option => option.name === field).required; @@ -176,7 +167,12 @@ export default { return this.formValues[name] || null; }, getValidation({ type, name, field_type, regex_pattern }) { - let regex = regex_pattern ? getRegexp(regex_pattern) : null; + const regex = regex_pattern ? getRegexp(regex_pattern) : null; + // FormKit caches the RegExp and calls .test() across keystrokes, so + // drop stateful g/y flags to stop lastIndex mutation flipping validity. + const matchRegex = regex + ? new RegExp(regex.source, regex.flags.replace(/[gy]/g, '')) + : null; const validations = { emailAddress: 'email', phoneNumber: ['startsWithPlus', 'isValidPhoneNumber'], @@ -186,27 +182,32 @@ export default { select: null, number: null, checkbox: false, - contact_attribute: regex ? [['matches', regex]] : null, - conversation_attribute: regex ? [['matches', regex]] : null, + contact_attribute: matchRegex ? [['matches', matchRegex]] : null, + conversation_attribute: matchRegex ? [['matches', matchRegex]] : null, }; const validationKeys = Object.keys(validations); const isRequired = this.isContactFieldRequired(name); - const validation = isRequired ? ['required'] : ['optional']; + const baseRules = isRequired ? [['required']] : [['optional']]; if ( - validationKeys.includes(name) || - validationKeys.includes(type) || - validationKeys.includes(field_type) + !validationKeys.includes(name) && + !validationKeys.includes(type) && + !validationKeys.includes(field_type) ) { - const validationType = - validations[type] || validations[name] || validations[field_type]; - const allValidations = validationType - ? validation.concat(validationType) - : validation; - return allValidations.join('|'); + return ''; } - return ''; + const validationType = + validations[type] || validations[name] || validations[field_type]; + if (!validationType) return baseRules; + + // Normalise into array-of-arrays so RegExp objects in `['matches', regex]` + // survive without being stringified by FormKit. + const extraRules = Array.isArray(validationType) + ? validationType.map(rule => (Array.isArray(rule) ? rule : [rule])) + : [[validationType]]; + + return baseRules.concat(extraRules); }, findFieldType(type) { if (type === 'link') { @@ -283,7 +284,7 @@ export default { } : undefined " - :label-class="context => `text-sm font-medium ${labelClass(context)}`" + label-class="text-sm font-medium text-n-slate-12" :input-class="context => inputClass(context)" :validation-messages="{ startsWithPlus: $t( @@ -302,7 +303,7 @@ export default { v-if="!hasActiveCampaign" name="message" type="textarea" - :label-class="context => `text-sm font-medium ${labelClass(context)}`" + label-class="text-sm font-medium text-n-slate-12" :input-class="context => inputClass(context)" :label="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.LABEL')" :placeholder="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.PLACEHOLDER')" @@ -330,16 +331,21 @@ export default { @apply mt-2; .formkit-inner { - input.error, - textarea.error, - select.error { - @apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 focus:outline-n-ruby-9 dark:focus:outline-n-ruby-9; - } - input[type='checkbox'] { @apply size-4 outline-none; } } + + &[data-invalid] { + .formkit-label { + @apply text-n-ruby-10; + } + .formkit-inner input, + .formkit-inner textarea, + .formkit-inner select { + @apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 focus:outline-n-ruby-9 dark:focus:outline-n-ruby-9; + } + } } [data-invalid] .formkit-message { From c1d167bd647297a5995e627a7dac56c6cfce4e16 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 5 May 2026 13:00:27 +0530 Subject: [PATCH 2/3] fix: prevent `--` signature delimiter rendering as `\` in bubble (#14134) # Pull Request Template ## Description Fixes https://linear.app/chatwoot/issue/CW-6903/signature-delimiter-renders-as-h2-when-using-enter-line-before **1**. Fixes an issue where the signature delimiter `--` gets parsed as an H2 when using **Enter** (new paragraph) before or after it, causing it to render as a bold `\` in the message bubble. * Ensures `--` renders as plain text * Aligns renderer with parser behavior (both disable `lheading`) * Prevents stray `\` from appearing as heading text **2**. Also fixes a related editor issue where toggling signature **off** leaves behind a stray `\` or `-- \`. * Strips blank paragraph markers (`\`) and dangling hard breaks (`\`) from ProseMirror serializer * Applied in both `appendSignature` and `removeSignature` * Replaces `trimEnd()` with shared helpers (`trimTrailingBlanks` / `stripTrailingBlankMarkers`) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? #### Screenshots **Before** image **After** image **Editor** https://linear.app/chatwoot/issue/CW-6903/signature-delimiter-renders-as-in-h2-when-using-enter-line#comment-5814b882 ### Steps #### Editor 1. Enable agent signature 2. Add and remove new lines around the signature using Enter/shift enter 3. Toggle signature off 4. Notice stray `\` or `-- \` remains #### Bubble 1. Enable agent signature 2. Send a message using Enter between lines 3. Verify `--` renders correctly (no H2, no bold `\`) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth --- .../dashboard/helper/editorHelper.js | 15 ++++++++- .../helper/specs/editorHelper.spec.js | 32 +++++++++++++++++++ .../shared/helpers/MessageFormatter.js | 1 + .../helpers/specs/MessageFormatter.spec.js | 7 ++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index 9839a04e0..491dd9d44 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -123,6 +123,13 @@ export function cleanSignature(signature) { } } +// Strip `\` hardbreak markers trailing `--` after a signature slice +const stripDelimiterHardbreaks = body => + body.replace(/(--)\s*(?:\\\s*)+$/, '$1'); + +// Strip standalone blank-paragraph markers (`\` on their own lines). +const stripTrailingBlankLine = body => body.replace(/\n(?:\s*\\\n)+$/, ''); + /** * Adds the signature delimiter to the beginning of the signature. * @@ -227,13 +234,19 @@ export function removeSignature(body, signature, channelType) { // trimming will ensure any spaces or new lines before the signature are removed // This means we will have the delimiter at the end if (signatureIndex > -1) { - newBody = newBody.substring(0, signatureIndex).trimEnd(); + newBody = stripDelimiterHardbreaks( + newBody.substring(0, signatureIndex) + ).trimEnd(); } // Remove delimiter if it's at the end if (newBody.endsWith(SIGNATURE_DELIMITER)) { // if the delimiter is at the end, remove it newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length); + // strip any trailing blank-line markers + if (signatureIndex > -1) { + newBody = stripTrailingBlankLine(newBody); + } } return newBody; diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index b06c36d42..c7822bde7 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -336,6 +336,38 @@ describe('removeSignature', () => { 'This is a test\n\n' ); }); + it('strips blank-paragraph marker before the delimiter', () => { + expect(removeSignature('hey\n\n\\\n--\n\nHello there', 'Hello there')).toBe( + 'hey' + ); + }); + it('strips multiple consecutive blank-paragraph markers before the delimiter', () => { + expect( + removeSignature('wewe\n\n\\\n\\\n\\\n--\n\nHello there', 'Hello there') + ).toBe('wewe'); + }); + it('strips dangling hardbreak when signature shared a paragraph with "--"', () => { + expect(removeSignature('hey\n\n--\\\nHello there', 'Hello there')).toBe( + 'hey\n\n' + ); + }); + it('preserves trailing backslash in user text when appending', () => { + expect(appendSignature('The path is C:\\', 'Best\nAgent')).toContain( + 'C:\\' + ); + expect(appendSignature('C:\\\n', 'Best\nAgent')).toContain('C:\\'); + expect(appendSignature('C:\\\n\n', 'Best\nAgent')).toContain('C:\\'); + }); + it('preserves trailing backslash in user text when removing', () => { + expect(removeSignature('C:\\\n--\n\nBest\nAgent', 'Best\nAgent')).toContain( + 'C:\\' + ); + expect(removeSignature('C:\\\n--', 'no matching sig')).toContain('C:\\'); + expect(removeSignature('C:\\\nBest\\\nAgent', 'Best\nAgent')).toContain( + 'C:\\' + ); + expect(removeSignature('notes\n\\\n--', 'no matching sig')).toContain('\\'); + }); }); describe('removeSignature with stripped signature', () => { diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js index c87209fd4..825132e13 100644 --- a/app/javascript/shared/helpers/MessageFormatter.js +++ b/app/javascript/shared/helpers/MessageFormatter.js @@ -42,6 +42,7 @@ const createMarkdownInstance = (linkify = true) => { quotes: '\u201c\u201d\u2018\u2019', maxNesting: 20, }) + .disable(['lheading']) .use(mentionPlugin) .use(imgResizeManager) .use(mila, { diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js index a685cb0da..12b84085c 100644 --- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js +++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js @@ -33,6 +33,13 @@ describe('#MessageFormatter', () => {

tool

` ); }); + + it('should not render a setext heading when text is followed by "--"', () => { + const message = 'hy\n\n\\\n\\-\\-\n\nHello there'; + const result = new MessageFormatter(message).formattedMessage; + expect(result).not.toMatch('

'); + expect(result).not.toMatch('

'); + }); }); describe('content with image and has "cw_image_height" query at the end of URL', () => { From 8cc36e19382943830c3f392a7c64f9c8208d7789 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 5 May 2026 14:16:24 +0530 Subject: [PATCH 3/3] feat: inline url embeds in article editor (#14284) --- .../widgets/WootWriter/FullEditor.vue | 16 ++++ .../dashboard/helper/markdownEmbeds.js | 11 +++ package.json | 3 +- pnpm-lock.yaml | 81 +++++++++++++++---- vite.config.ts | 5 +- 5 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 app/javascript/dashboard/helper/markdownEmbeds.js diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 1e1ab9756..8bd3e9457 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue @@ -13,6 +13,9 @@ import { triggerCharacters, } from '@chatwoot/prosemirror-schema/src/mentions/plugin'; import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image'; +import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview'; +import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph'; +import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds'; import { toggleMark } from 'prosemirror-commands'; import { wrapInList } from 'prosemirror-schema-list'; import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common'; @@ -77,6 +80,8 @@ export default { plugins: [ imagePastePlugin(this.handleImageUpload), this.createSlashPlugin(), + embedPreviewPlugin(markdownEmbeds), + trailingParagraphPlugin(), ], isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection showSlashMenu: false, @@ -113,6 +118,12 @@ export default { this.focusEditorInputField(); } }, + beforeUnmount() { + if (editorView) { + editorView.destroy(); + editorView = null; + } + }, methods: { createSlashPlugin() { return suggestionsPlugin({ @@ -488,4 +499,9 @@ export default { max-height: 7.5rem; overflow: auto; } + +.ProseMirror .cw-embed-preview { + max-width: 36rem; + margin: 0.5rem 0 1rem; +} diff --git a/app/javascript/dashboard/helper/markdownEmbeds.js b/app/javascript/dashboard/helper/markdownEmbeds.js new file mode 100644 index 000000000..116290220 --- /dev/null +++ b/app/javascript/dashboard/helper/markdownEmbeds.js @@ -0,0 +1,11 @@ +import config from '../../../../config/markdown_embeds.yml'; + +// Gists rely on document.write() and can't render inline in the editor. +const NON_PREVIEWABLE_EMBEDS = new Set(['github_gist']); + +export const embeds = Object.entries(config) + .filter(([key]) => !NON_PREVIEWABLE_EMBEDS.has(key)) + .map(([, { regex, template }]) => ({ + regex: new RegExp(regex), + template, + })); diff --git a/package.json b/package.json index 8d45183e0..bb5b59ba3 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.10", + "@chatwoot/prosemirror-schema": "1.3.11", "@chatwoot/utils": "^0.0.52", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", @@ -121,6 +121,7 @@ "@iconify-json/ri": "^1.2.6", "@iconify-json/teenyicons": "^1.2.2", "@intlify/eslint-plugin-vue-i18n": "^3.2.0", + "@rollup/plugin-yaml": "^4.1.2", "@size-limit/file": "^8.2.4", "@vitest/coverage-v8": "3.0.5", "@vue/test-utils": "^2.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab9e7b241..2a3aee897 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,8 +26,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.10 - version: 1.3.10 + specifier: 1.3.11 + version: 1.3.11 '@chatwoot/utils': specifier: ^0.0.52 version: 0.0.52 @@ -281,6 +281,9 @@ importers: '@intlify/eslint-plugin-vue-i18n': specifier: ^3.2.0 version: 3.2.0(eslint@8.57.0) + '@rollup/plugin-yaml': + specifier: ^4.1.2 + version: 4.1.2(rollup@4.59.0) '@size-limit/file': specifier: ^8.2.4 version: 8.2.6(size-limit@8.2.6) @@ -460,8 +463,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.10': - resolution: {integrity: sha512-MtOXqFPHptFHu/AoIPhQ9TskVXOxOXCgBY/tgAtCAQRut978F7I3QxozQBECBz83ubsoXnBecpNjGNq0OPgONw==} + '@chatwoot/prosemirror-schema@1.3.11': + resolution: {integrity: sha512-+GptIqY73/EtojrhAKX4UKwZF7NUB47DMzgYxmx8Vu07DLKCGe+8JnbHJnR9oBy8p5sn5VOO1ihNf/4Pg2RzIQ==} '@chatwoot/utils@0.0.52': resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==} @@ -1122,6 +1125,24 @@ packages: '@rails/ujs@7.1.400': resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==} + '@rollup/plugin-yaml@4.1.2': + resolution: {integrity: sha512-RpupciIeZMUqhgFE97ba0s98mOFS7CWzN3EJNhJkqSv9XLlWYtwVdtE6cDw6ASOF/sZVFS7kRJXftaqM2Vakdw==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: '>=4.59.0' + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: '>=4.59.0' + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -3016,8 +3037,8 @@ packages: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsdom@20.0.3: @@ -3551,6 +3572,10 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -4315,6 +4340,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tosource@2.0.0-alpha.3: + resolution: {integrity: sha512-KAB2lrSS48y91MzFPFuDg4hLbvDiyTjOVgaK7Erw+5AmZXNq4sFRVn8r6yxSLuNs15PaokrDRpS61ERY9uZOug==} + engines: {node: '>=10'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -4976,7 +5005,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.10': + '@chatwoot/prosemirror-schema@1.3.11': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.7.1 @@ -5328,7 +5357,7 @@ snapshots: globals: 13.24.0 ignore: 5.2.4 import-fresh: 3.3.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -5342,7 +5371,7 @@ snapshots: globals: 14.0.0 ignore: 5.2.4 import-fresh: 3.3.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -5547,7 +5576,7 @@ snapshots: ignore: 6.0.2 import-fresh: 3.3.0 is-language-code: 3.1.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 json5: 2.2.3 jsonc-eslint-parser: 2.4.0 lodash: 4.17.21 @@ -5689,6 +5718,22 @@ snapshots: '@rails/ujs@7.1.400': {} + '@rollup/plugin-yaml@4.1.2(rollup@4.59.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + js-yaml: 4.1.1 + tosource: 2.0.0-alpha.3 + optionalDependencies: + rollup: 4.59.0 + + '@rollup/pluginutils@5.3.0(rollup@4.59.0)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.59.0 + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -7184,7 +7229,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 - js-yaml: 4.1.0 + js-yaml: 4.1.1 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 @@ -7851,7 +7896,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -8421,6 +8466,8 @@ snapshots: picomatch@2.3.1: {} + picomatch@4.0.4: {} + pidtree@0.6.0: {} pify@2.3.0: {} @@ -8723,7 +8770,7 @@ snapshots: prosemirror-dropcursor@1.8.1: dependencies: prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 prosemirror-view: 1.34.1 prosemirror-gapcursor@1.3.2: @@ -8736,14 +8783,14 @@ snapshots: prosemirror-history@1.4.1: dependencies: prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 prosemirror-view: 1.34.1 rope-sequence: 1.3.2 prosemirror-inputrules@1.4.0: dependencies: prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 prosemirror-keymap@1.2.2: dependencies: @@ -8783,7 +8830,7 @@ snapshots: prosemirror-keymap: 1.2.2 prosemirror-model: 1.22.3 prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 prosemirror-view: 1.34.1 prosemirror-transform@1.10.0: @@ -9308,6 +9355,8 @@ snapshots: dependencies: is-number: 7.0.0 + tosource@2.0.0-alpha.3: {} + totalist@3.0.1: {} tough-cookie@4.1.4: diff --git a/vite.config.ts b/vite.config.ts index e06b0f1fb..17d47fc76 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,6 +22,7 @@ import { defineConfig } from 'vite'; import ruby from 'vite-plugin-ruby'; import path from 'path'; import vue from '@vitejs/plugin-vue'; +import yaml from '@rollup/plugin-yaml'; const isLibraryMode = process.env.BUILD_MODE === 'library'; const isTestMode = process.env.TEST === 'true'; @@ -34,12 +35,12 @@ const vueOptions = { }, }; -let plugins = [ruby(), vue(vueOptions)]; +let plugins = [ruby(), vue(vueOptions), yaml()]; if (isLibraryMode) { plugins = []; } else if (isTestMode) { - plugins = [vue(vueOptions)]; + plugins = [vue(vueOptions), yaml()]; } export default defineConfig({