From bba5626dea8c2a4f141128a71ecf957fc33fa8a7 Mon Sep 17 00:00:00 2001 From: Alex Turchyn Date: Fri, 3 Jul 2026 02:14:55 +0300 Subject: [PATCH] add ability to edit html email --- app/javascript/application.js | 2 + app/javascript/elements/email_editor.js | 78 ++- app/javascript/elements/html_editor.js | 649 ++++++++++++++++++ app/javascript/elements/markdown_editor.js | 2 +- .../template_builder/field_settings.vue | 1 + .../_editor_toolbar.html.erb | 53 ++ .../_email_body_editor.html.erb | 1 + .../_markdown_editor.html.erb | 92 +-- .../submissions/_send_email_base.html.erb | 9 +- .../_submitter_completed_email_form.html.erb | 2 +- ...bmitter_documents_copy_email_form.html.erb | 2 +- .../_submitter_invitation_email_form.html.erb | 4 +- ...mitter_view_invitation_email_form.html.erb | 2 +- lib/email_messages.rb | 5 + 14 files changed, 814 insertions(+), 88 deletions(-) create mode 100644 app/javascript/elements/html_editor.js create mode 100644 app/views/personalization_settings/_editor_toolbar.html.erb create mode 100644 app/views/personalization_settings/_email_body_editor.html.erb diff --git a/app/javascript/application.js b/app/javascript/application.js index 6aa39fc8..75178c96 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -42,6 +42,7 @@ import RequiredCheckboxGroup from './elements/required_checkbox_group' import PageContainer from './elements/page_container' import EmailEditor from './elements/email_editor' import MarkdownEditor from './elements/markdown_editor' +import HtmlEditor from './elements/html_editor' import MountOnClick from './elements/mount_on_click' import RemoveOnEvent from './elements/remove_on_event' import ScrollTo from './elements/scroll_to' @@ -135,6 +136,7 @@ safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup) safeRegisterElement('page-container', PageContainer) safeRegisterElement('email-editor', EmailEditor) safeRegisterElement('markdown-editor', MarkdownEditor) +safeRegisterElement('html-editor', HtmlEditor) safeRegisterElement('mount-on-click', MountOnClick) safeRegisterElement('remove-on-event', RemoveOnEvent) safeRegisterElement('scroll-to', ScrollTo) diff --git a/app/javascript/elements/email_editor.js b/app/javascript/elements/email_editor.js index 38f0d3b4..030eeaa4 100644 --- a/app/javascript/elements/email_editor.js +++ b/app/javascript/elements/email_editor.js @@ -9,8 +9,9 @@ function loadCodeMirror () { import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'), import(/* webpackChunkName: "email-editor" */ '@codemirror/language'), import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'), + import(/* webpackChunkName: "email-editor" */ '@codemirror/lint'), import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow') - ]).then(([view, commands, language, html, htmlflow]) => { + ]).then(([view, commands, language, html, lint, htmlflow]) => { return { minimalSetup: [ commands.history(), @@ -19,6 +20,8 @@ function loadCodeMirror () { ], EditorView: view.EditorView, html: html.html, + htmlLanguage: html.htmlLanguage, + linter: lint.linter, htmlflow: htmlflow.default || htmlflow } }) @@ -46,6 +49,70 @@ export default targetable(class extends HTMLElement { this.previewViewTab.addEventListener('click', this.showPreviewView) this.codeViewTab.addEventListener('click', this.showCodeView) + + this.form = this.closest('form') + this.form?.addEventListener('submit', this.validateOnSubmit) + } + + disconnectedCallback () { + this.form?.removeEventListener('submit', this.validateOnSubmit) + } + + validateOnSubmit = (e) => { + if (!this.htmlLanguage) return + + const bodyType = this.form.querySelector('input[name$="[body_type]"]:checked')?.value + + if (bodyType && bodyType !== 'html') return + + const diagnostics = this.buildDiagnostics(this.input.value) + + if (diagnostics.length === 0) return + + e.preventDefault() + + this.showCodeView() + + const pos = Math.min(diagnostics[0].from, this.editorView.state.doc.length) + + this.editorView.dispatch({ selection: { anchor: pos }, scrollIntoView: true }) + this.editorView.focus() + + alert(diagnostics[0].message) + } + + buildDiagnostics (value) { + const diagnostics = [] + + if (!value.trim()) return diagnostics + + if (!/^\s*(]*>\s*)? tag' + }) + } + + const seen = new Set() + + this.htmlLanguage.parser.parse(value).iterate({ + enter: (node) => { + if (!node.type.isError || seen.has(node.from) || seen.size >= 20) return + + seen.add(node.from) + + diagnostics.push({ + from: node.from, + to: Math.min(node.to + 1, value.length), + severity: 'error', + message: 'The email template contains invalid HTML' + }) + } + }) + + return diagnostics } showCodeView = () => { @@ -76,7 +143,9 @@ export default targetable(class extends HTMLElement { this.input = this.querySelector('input[type="hidden"]') this.input.style.display = 'none' - const { EditorView, minimalSetup, html, htmlflow } = await loadCodeMirror() + const { EditorView, minimalSetup, html, htmlLanguage, linter, htmlflow } = await loadCodeMirror() + + this.htmlLanguage = htmlLanguage this.editorView = new EditorView({ doc: this.input.value, @@ -85,8 +154,11 @@ export default targetable(class extends HTMLElement { html(), minimalSetup, EditorView.lineWrapping, + linter((view) => this.buildDiagnostics(view.state.doc.toString()), { delay: 600 }), EditorView.updateListener.of(update => { - if (update.docChanged) this.input.value = update.state.doc.toString() + if (update.docChanged) { + this.input.value = update.state.doc.toString() + } }), EditorView.theme({ '&': { diff --git a/app/javascript/elements/html_editor.js b/app/javascript/elements/html_editor.js new file mode 100644 index 00000000..8c9a2d2c --- /dev/null +++ b/app/javascript/elements/html_editor.js @@ -0,0 +1,649 @@ +import { target, targetable } from '@github/catalyst/lib/targetable' +import { actionable } from '@github/catalyst/lib/actionable' +import { LinkTooltip } from './markdown_editor' + +async function loadTiptap () { + const [core, document, text, hardBreak, gapcursor, dropcursor, extensions, pmState, pmView] = await Promise.all([ + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-gapcursor'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-dropcursor'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view') + ]) + + return { + Editor: core.Editor, + Extension: core.Extension, + Node: core.Node, + Mark: core.Mark, + Document: document.default || document, + Text: text.default || text, + HardBreak: hardBreak.default || hardBreak, + Gapcursor: gapcursor.default || gapcursor, + Dropcursor: dropcursor.default || dropcursor, + UndoRedo: extensions.UndoRedo, + Plugin: pmState.Plugin, + Decoration: pmView.Decoration, + DecorationSet: pmView.DecorationSet + } +} + +const editorStylesheet = new CSSStyleSheet() + +editorStylesheet.replaceSync(` +:host { + display: block; + max-height: 360px; + overflow: auto; + border-radius: 0 0 1rem 1rem; +} + +.ProseMirror { + word-wrap: break-word; + -webkit-font-variant-ligatures: none; + font-variant-ligatures: none; + font-feature-settings: "liga" 0; + outline: none; + min-height: 220px; + padding: 12px; +} + +img.ProseMirror-separator { + display: inline !important; + border: none !important; + margin: 0 !important; + width: 0 !important; + height: 0 !important; +} + +.ProseMirror-gapcursor { + display: none; + pointer-events: none; + position: absolute; + margin: 0; +} + +.ProseMirror-gapcursor:after { + content: ""; + display: block; + position: absolute; + top: -2px; + width: 20px; + border-top: 1px solid black; + animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; +} + +@keyframes ProseMirror-cursor-blink { + to { + visibility: hidden; + } +} + +.ProseMirror-hideselection *::selection { + background: transparent; +} + +.ProseMirror-hideselection *::-moz-selection { + background: transparent; +} + +.ProseMirror-hideselection * { + caret-color: transparent; +} + +.ProseMirror-focused .ProseMirror-gapcursor { + display: block; +} + +.variable-highlight { + background-color: #fef3c7; + padding: 1px 2px; + border-radius: 4px; +} +`) + +function collectDomAttrs (dom) { + const attrs = {} + + for (let i = 0; i < dom.attributes.length; i++) { + attrs[dom.attributes[i].name] = dom.attributes[i].value + } + + return { htmlAttrs: attrs } +} + +function collectSpanDomAttrs (dom) { + const result = collectDomAttrs(dom) + + if (result.htmlAttrs.style) { + const temp = document.createElement('span') + + temp.style.cssText = result.htmlAttrs.style + + if (['bold', '700'].includes(temp.style.fontWeight)) { + temp.style.removeProperty('font-weight') + } + + if (temp.style.fontStyle === 'italic') { + temp.style.removeProperty('font-style') + } + + if (temp.style.textDecoration === 'underline') { + temp.style.removeProperty('text-decoration') + } + + if (temp.style.cssText) { + result.htmlAttrs.style = temp.style.cssText + } else { + delete result.htmlAttrs.style + } + } + + return result +} + +function buildExtensions ({ Node, Mark, Extension, Plugin, Decoration, DecorationSet }) { + const blockNode = (name, tag, content, extra = {}) => Node.create({ + name, + group: 'block', + content: content || 'block+', + ...extra, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag, getAttrs: collectDomAttrs }] + }, + renderHTML ({ node }) { + return [tag, node.attrs.htmlAttrs, 0] + } + }) + + const attrsMark = (name, tag) => Mark.create({ + name, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag, getAttrs: collectDomAttrs }] + }, + renderHTML ({ mark }) { + return [tag, mark.attrs.htmlAttrs, 0] + } + }) + + const SpanMark = Mark.create({ + name: 'span', + excludes: '', + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag: 'span', getAttrs: collectSpanDomAttrs }] + }, + renderHTML ({ mark }) { + return ['span', mark.attrs.htmlAttrs, 0] + } + }) + + const toggleMark = (name, renderTag, parseRules, shortcuts) => Mark.create({ + name, + parseHTML () { + return parseRules + }, + renderHTML () { + return [renderTag, 0] + }, + addCommands () { + const commandName = `toggle${name[0].toUpperCase()}${name.slice(1)}` + + return { + [commandName]: () => ({ commands }) => commands.toggleMark(name) + } + }, + addKeyboardShortcuts () { + return { + [shortcuts]: () => this.editor.commands.toggleMark(name) + } + } + }) + + const Heading = Node.create({ + name: 'heading', + group: 'block', + content: 'inline*', + addAttributes () { + return { + htmlAttrs: { default: {} }, + level: { default: 1 } + } + }, + parseHTML () { + return [1, 2, 3, 4, 5, 6].map((level) => ({ + tag: `h${level}`, + getAttrs: (dom) => ({ ...collectDomAttrs(dom), level }) + })) + }, + renderHTML ({ node }) { + return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0] + } + }) + + const ImageNode = Node.create({ + name: 'image', + inline: true, + group: 'inline', + draggable: true, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag: 'img', getAttrs: collectDomAttrs }] + }, + renderHTML ({ node }) { + return ['img', node.attrs.htmlAttrs] + } + }) + + const HrNode = Node.create({ + name: 'horizontalRule', + group: 'block', + atom: true, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag: 'hr', getAttrs: collectDomAttrs }] + }, + renderHTML ({ node }) { + return ['hr', node.attrs.htmlAttrs] + } + }) + + const StyleNode = Node.create({ + name: 'style', + group: 'block', + atom: true, + selectable: false, + addAttributes () { + return { + htmlAttrs: { default: {} }, + css: { default: '' } + } + }, + parseHTML () { + return [{ tag: 'style', getAttrs: (dom) => ({ ...collectDomAttrs(dom), css: dom.textContent }) }] + }, + renderHTML ({ node }) { + return ['style', node.attrs.htmlAttrs, node.attrs.css] + } + }) + + const EmptySpanNode = Node.create({ + name: 'emptySpan', + inline: true, + group: 'inline', + atom: true, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ + tag: 'span', + priority: 60, + getAttrs (dom) { + if (dom.childNodes.length === 0 && dom.attributes.length > 0) { + return collectDomAttrs(dom) + } + + return false + } + }] + }, + renderHTML ({ node }) { + return ['span', node.attrs.htmlAttrs] + } + }) + + const LinkMark = Mark.create({ + name: 'link', + inclusive: true, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag: 'a', getAttrs: collectDomAttrs }] + }, + renderHTML ({ mark }) { + return ['a', mark.attrs.htmlAttrs, 0] + }, + addCommands () { + return { + setLink: ({ href }) => ({ editor, commands }) => { + const htmlAttrs = { ...(editor.getAttributes('link').htmlAttrs || {}), href } + + return commands.setMark('link', { htmlAttrs }) + }, + unsetLink: () => ({ commands }) => commands.unsetMark('link', { extendEmptyMarkRange: true }) + } + } + }) + + const buildDecorations = (doc) => { + const decorations = [] + const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g + + doc.descendants((node, pos) => { + if (!node.isText) return + + let match + + while ((match = regex.exec(node.text)) !== null) { + decorations.push( + Decoration.inline(pos + match.index, pos + match.index + match[0].length, { + class: 'variable-highlight' + }) + ) + } + }) + + return DecorationSet.create(doc, decorations) + } + + const VariableHighlight = Extension.create({ + name: 'variableHighlight', + addProseMirrorPlugins () { + return [new Plugin({ + state: { + init (_, { doc }) { + return buildDecorations(doc) + }, + apply (tr, oldSet) { + return tr.docChanged ? buildDecorations(tr.doc) : oldSet + } + }, + props: { + decorations (state) { + return this.getState(state) + } + } + })] + } + }) + + return [ + blockNode('paragraph', 'p', 'inline*'), + Heading, + blockNode('section', 'section'), + blockNode('article', 'article', null, { isolating: true }), + blockNode('header', 'header', null, { isolating: true }), + blockNode('footer', 'footer', null, { isolating: true }), + blockNode('div', 'div'), + blockNode('center', 'center'), + blockNode('blockquote', 'blockquote'), + blockNode('pre', 'pre'), + blockNode('orderedList', 'ol', '(listItem | block)+'), + blockNode('bulletList', 'ul', '(listItem | block)+'), + blockNode('listItem', 'li', 'block+', { group: null }), + blockNode('table', 'table', '(colgroup | tableHead | tableBody | tableFoot | tableRow)+'), + blockNode('tableHead', 'thead', 'tableRow+', { group: null }), + blockNode('tableBody', 'tbody', 'tableRow+', { group: null }), + blockNode('tableFoot', 'tfoot', 'tableRow+', { group: null }), + blockNode('tableRow', 'tr', '(tableCell | tableHeader)+', { group: null }), + blockNode('tableCell', 'td', 'block*', { group: null }), + blockNode('tableHeader', 'th', 'block*', { group: null }), + blockNode('colgroup', 'colgroup', 'col*', { group: null }), + Node.create({ + name: 'col', + atom: true, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag: 'col', getAttrs: collectDomAttrs }] + }, + renderHTML ({ node }) { + return ['col', node.attrs.htmlAttrs] + } + }), + ImageNode, + HrNode, + StyleNode, + EmptySpanNode, + SpanMark, + LinkMark, + toggleMark('bold', 'strong', [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }], 'Mod-b'), + toggleMark('italic', 'em', [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }], 'Mod-i'), + toggleMark('underline', 'u', [{ tag: 'u' }, { style: 'text-decoration=underline' }], 'Mod-u'), + toggleMark('strike', 's', [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }], 'Mod-Shift-s'), + attrsMark('subscript', 'sub'), + attrsMark('superscript', 'sup'), + VariableHighlight + ] +} + +export default actionable(targetable(class extends HTMLElement { + static [target.static] = [ + 'textarea', + 'editorElement', + 'boldButton', + 'italicButton', + 'underlineButton', + 'linkButton', + 'linkTooltipTemplate' + ] + + async connectedCallback () { + if (!this.textarea || !this.editorElement) return + + this.textarea.style.display = 'none' + this.adjustShortcutsForPlatform() + + const tiptap = await loadTiptap() + + const { Editor, Extension, Document, Text, HardBreak, UndoRedo, Gapcursor, Dropcursor } = tiptap + + this.emailDocument = new DOMParser().parseFromString(this.textarea.value, 'text/html') + + const shadow = this.editorElement.attachShadow({ mode: 'open' }) + + shadow.adoptedStyleSheets = [editorStylesheet] + + this.emailDocument.head.querySelectorAll('style').forEach((style) => { + shadow.appendChild(style.cloneNode(true)) + }) + + const container = document.createElement('div') + const bodyStyle = this.emailDocument.body.getAttribute('style') + + if (bodyStyle) container.setAttribute('style', bodyStyle) + + shadow.appendChild(container) + + const LinkShortcut = Extension.create({ + name: 'linkShortcut', + addKeyboardShortcuts: () => ({ + 'Mod-k': () => { + this.toggleLink() + + return true + } + }) + }) + + this.editor = new Editor({ + element: container, + extensions: [ + Document, + Text, + HardBreak, + UndoRedo, + Gapcursor, + Dropcursor, + ...buildExtensions(tiptap), + LinkShortcut + ], + content: this.emailDocument.body.innerHTML, + injectCSS: false, + editorProps: { + attributes: { + dir: 'auto' + }, + handleDOMEvents: { + click: (_, event) => { + if (event.target.closest('a')) event.preventDefault() + + return false + } + } + }, + onUpdate: ({ editor }) => { + this.emailDocument.body.innerHTML = editor.getHTML() + + this.textarea.value = this.emailDocument.documentElement.outerHTML + this.textarea.dispatchEvent(new Event('input', { bubbles: true })) + }, + onSelectionUpdate: ({ editor }) => { + this.updateToolbarState() + this.handleLinkTooltip(editor) + }, + onBlur: () => { + setTimeout(() => { + if (!this.linkTooltip.tooltip.contains(document.activeElement)) { + this.linkTooltip.hide() + } + }, 0) + } + }) + + this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate) + } + + adjustShortcutsForPlatform () { + if ((navigator.userAgentData?.platform)?.toLowerCase()?.includes('mac')) { + this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => { + const tip = tooltip.getAttribute('data-tip') + + if (tip && tip.includes('Ctrl')) { + tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘')) + } + }) + } + } + + bold (e) { + e.preventDefault() + + this.editor.chain().focus().toggleBold().run() + this.updateToolbarState() + } + + italic (e) { + e.preventDefault() + + this.editor.chain().focus().toggleItalic().run() + this.updateToolbarState() + } + + underline (e) { + e.preventDefault() + + this.editor.chain().focus().toggleUnderline().run() + this.updateToolbarState() + } + + linkSelection (e) { + e.preventDefault() + + this.toggleLink() + this.updateToolbarState() + } + + undo (e) { + e.preventDefault() + + this.editor.chain().focus().undo().run() + this.updateToolbarState() + } + + redo (e) { + e.preventDefault() + + this.editor.chain().focus().redo().run() + this.updateToolbarState() + } + + updateToolbarState () { + this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold')) + this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic')) + this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline')) + this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link')) + } + + handleLinkTooltip (editor) { + const { from } = editor.state.selection + const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link') + + if (!mark) { + if (this.linkTooltip.isVisible()) this.linkTooltip.hide() + + return + } + + if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return + + let linkStart = from + const start = editor.state.doc.resolve(from).start() + + for (let i = from - 1; i >= start; i--) { + if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) { + linkStart = i + } else { + break + } + } + + this.linkTooltip.hide() + this.linkTooltip.show(mark.attrs.htmlAttrs?.href, linkStart > start ? linkStart - 1 : linkStart) + this.linkTooltip.currentMark = mark + } + + toggleLink () { + if (this.editor.isActive('link')) { + this.linkTooltip.hide() + this.editor.chain().focus().extendMarkRange('link').unsetLink().run() + this.updateToolbarState() + } else { + const { from } = this.editor.state.selection + + this.linkTooltip.hide() + this.linkTooltip.show(this.editor.getAttributes('link').htmlAttrs?.href, from, { focus: true }) + } + } + + insertVariable (e) { + const variable = e.target.closest('[data-variable]')?.dataset.variable + + if (variable) { + const { from, to } = this.editor.state.selection + + if (variable.includes('link') && from !== to) { + this.editor.chain().focus().setLink({ href: `{${variable}}` }).run() + } else { + this.editor.chain().focus().insertContent(`{${variable}}`).run() + } + } + } + + disconnectedCallback () { + this.linkTooltip?.hide() + + if (this.editor) { + this.editor.destroy() + } + } +})) diff --git a/app/javascript/elements/markdown_editor.js b/app/javascript/elements/markdown_editor.js index b9937598..bd2508a5 100644 --- a/app/javascript/elements/markdown_editor.js +++ b/app/javascript/elements/markdown_editor.js @@ -35,7 +35,7 @@ function loadTiptap () { })) } -class LinkTooltip { +export class LinkTooltip { constructor (container, editor, templateEl) { this.container = container this.editor = editor diff --git a/app/javascript/template_builder/field_settings.vue b/app/javascript/template_builder/field_settings.vue index c22ee574..a5930565 100644 --- a/app/javascript/template_builder/field_settings.vue +++ b/app/javascript/template_builder/field_settings.vue @@ -830,6 +830,7 @@ export default { } else if (format === 'percent') { return `${number}%` } else if (format === 'percent_space') { + // eslint-disable-next-line no-irregular-whitespace return `${String(number).replace('.', ',')} %` } else { return number diff --git a/app/views/personalization_settings/_editor_toolbar.html.erb b/app/views/personalization_settings/_editor_toolbar.html.erb new file mode 100644 index 00000000..d6c58698 --- /dev/null +++ b/app/views/personalization_settings/_editor_toolbar.html.erb @@ -0,0 +1,53 @@ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+ <% if local_assigns[:variables]&.any? %> + <% variable_labels = { 'account.name' => t('variables.account_name'), 'submitter.link' => t('variables.submitter_link'), 'template.name' => t('variables.template_name'), 'submission.submitters' => t('variables.submission_submitters'), 'submission.link' => t('variables.submission_link'), 'documents.link' => t('variables.documents_link') } %> + + <% end %> +
diff --git a/app/views/personalization_settings/_email_body_editor.html.erb b/app/views/personalization_settings/_email_body_editor.html.erb new file mode 100644 index 00000000..f98ad9fb --- /dev/null +++ b/app/views/personalization_settings/_email_body_editor.html.erb @@ -0,0 +1 @@ +<%= render 'personalization_settings/markdown_editor', name:, value:, variables: local_assigns[:variables] %> diff --git a/app/views/personalization_settings/_markdown_editor.html.erb b/app/views/personalization_settings/_markdown_editor.html.erb index 5e43e5cb..dd04a95f 100644 --- a/app/views/personalization_settings/_markdown_editor.html.erb +++ b/app/views/personalization_settings/_markdown_editor.html.erb @@ -1,76 +1,18 @@ -<% if value.to_s.start_with?(' - - <%= text_area_tag name, value, required: true, class: 'base-input w-full py-2 !rounded-2xl', dir: 'auto', style: 'max-height: 400px' %> - -<% else %> - - -
-
-
-
- -
-
- -
-
- -
-
- -
-
-
-
-
- -
-
- -
-
- <% if local_assigns[:variables]&.any? %> - <% variable_labels = { 'account.name' => t('variables.account_name'), 'submitter.link' => t('variables.submitter_link'), 'template.name' => t('variables.template_name'), 'submission.submitters' => t('variables.submission_submitters'), 'submission.link' => t('variables.submission_link'), 'documents.link' => t('variables.documents_link') } %> - - <% end %> -
-
+ + +
+ <%= render 'personalization_settings/editor_toolbar', editor_tag: 'markdown-editor', variables: local_assigns[:variables] %> +
+
+ <%= hidden_field_tag name, value, required: true, data: { target: 'markdown-editor.textarea' } %> +
diff --git a/app/views/submissions/_send_email_base.html.erb b/app/views/submissions/_send_email_base.html.erb index 92ab06ab..36650851 100644 --- a/app/views/submissions/_send_email_base.html.erb +++ b/app/views/submissions/_send_email_base.html.erb @@ -39,10 +39,11 @@
<% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %> <% view_config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY) %> +<% config_body = (config.value['body_type'] == 'html' && config.value['html_body'].presence) || config.value['body'] %> <% view_template_subject = template&.preferences&.dig('invitation_view_email_subject').presence %> <% view_template_body = template&.preferences&.dig('invitation_view_email_body').presence %> <% default_subject = template&.preferences&.dig('request_email_subject').presence || config.value['subject'] %> -<% default_body = template&.preferences&.dig('request_email_body').presence || config.value['body'] %> +<% default_body = template&.preferences&.dig('request_email_body').presence || config_body %> <% is_edit_viewer = local_assigns[:submitter] && local_assigns[:viewer_submitter_uuids].include?(local_assigns[:submitter].uuid) %>
<%= ff.label :completed_notification_email_body, t('email_body'), class: 'label' %> - <%= render 'personalization_settings/markdown_editor', name: ff.field_name(:completed_notification_email_body), value: ff.object.completed_notification_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY] %> + <%= render 'personalization_settings/email_body_editor', name: ff.field_name(:completed_notification_email_body), value: ff.object.completed_notification_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY], html_textarea: true %>
<% end %> <% end %> diff --git a/app/views/templates_preferences/_submitter_documents_copy_email_form.html.erb b/app/views/templates_preferences/_submitter_documents_copy_email_form.html.erb index faa9e617..438b108f 100644 --- a/app/views/templates_preferences/_submitter_documents_copy_email_form.html.erb +++ b/app/views/templates_preferences/_submitter_documents_copy_email_form.html.erb @@ -21,7 +21,7 @@
<%= ff.label :documents_copy_email_body, t('email_body'), class: 'label' %> - <%= render 'personalization_settings/markdown_editor', name: ff.field_name(:documents_copy_email_body), value: ff.object.documents_copy_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY] %> + <%= render 'personalization_settings/email_body_editor', name: ff.field_name(:documents_copy_email_body), value: ff.object.documents_copy_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY], html_textarea: true %>
<% if can?(:manage, :reply_to) %>
diff --git a/app/views/templates_preferences/_submitter_invitation_email_form.html.erb b/app/views/templates_preferences/_submitter_invitation_email_form.html.erb index 8cae87e1..603ee455 100644 --- a/app/views/templates_preferences/_submitter_invitation_email_form.html.erb +++ b/app/views/templates_preferences/_submitter_invitation_email_form.html.erb @@ -31,7 +31,7 @@
<%= ff.label :request_email_body, t('email_body'), class: 'label' %> - <%= render 'personalization_settings/markdown_editor', name: ff.field_name(:request_email_body), value: ff.object.request_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %> + <%= render 'personalization_settings/email_body_editor', name: ff.field_name(:request_email_body), value: ff.object.request_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY], html_textarea: true %>
<% end %> @@ -67,7 +67,7 @@
- <%= render 'personalization_settings/markdown_editor', name: 'template[preferences][submitters][][request_email_body]', value: submitter_email_values.last, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %> + <%= render 'personalization_settings/email_body_editor', name: 'template[preferences][submitters][][request_email_body]', value: submitter_email_values.last, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY], html_textarea: true %>
<% end %> diff --git a/app/views/templates_preferences/_submitter_view_invitation_email_form.html.erb b/app/views/templates_preferences/_submitter_view_invitation_email_form.html.erb index 491f1e95..707f3a9a 100644 --- a/app/views/templates_preferences/_submitter_view_invitation_email_form.html.erb +++ b/app/views/templates_preferences/_submitter_view_invitation_email_form.html.erb @@ -21,7 +21,7 @@
<%= ff.label :invitation_view_email_body, t('email_body'), class: 'label' %> - <%= render 'personalization_settings/markdown_editor', name: ff.field_name(:invitation_view_email_body), value: ff.object.invitation_view_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY] %> + <%= render 'personalization_settings/email_body_editor', name: ff.field_name(:invitation_view_email_body), value: ff.object.invitation_view_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY], html_textarea: true %>
<% end %> <% end %> diff --git a/lib/email_messages.rb b/lib/email_messages.rb index 08436730..eed9d0a5 100644 --- a/lib/email_messages.rb +++ b/lib/email_messages.rb @@ -8,9 +8,14 @@ module EmailMessages ASSET_REGEXP = Regexp.union(STYLE_REGEXP, BASE64_REGEXP) ASSET_PREFIX = '[[asset:' PLACEHOLDER_REGEXP = /\[\[asset:(\h{40})\]\]/ + HTML_MIME_TYPES = ['text/html', 'application/xhtml+xml'].freeze module_function + def html_body?(content) + content.present? && HTML_MIME_TYPES.include?(Marcel::MimeType.for(content.dup)) + end + def find_or_create_for_account_user(account, user, subject, body) subject = I18n.t(:you_are_invited_to_sign_a_document) if subject.blank?