add ability to edit html email
This commit is contained in:
committed by
Pete Matsyburka
parent
bdf5e58929
commit
bba5626dea
@@ -42,6 +42,7 @@ import RequiredCheckboxGroup from './elements/required_checkbox_group'
|
|||||||
import PageContainer from './elements/page_container'
|
import PageContainer from './elements/page_container'
|
||||||
import EmailEditor from './elements/email_editor'
|
import EmailEditor from './elements/email_editor'
|
||||||
import MarkdownEditor from './elements/markdown_editor'
|
import MarkdownEditor from './elements/markdown_editor'
|
||||||
|
import HtmlEditor from './elements/html_editor'
|
||||||
import MountOnClick from './elements/mount_on_click'
|
import MountOnClick from './elements/mount_on_click'
|
||||||
import RemoveOnEvent from './elements/remove_on_event'
|
import RemoveOnEvent from './elements/remove_on_event'
|
||||||
import ScrollTo from './elements/scroll_to'
|
import ScrollTo from './elements/scroll_to'
|
||||||
@@ -135,6 +136,7 @@ safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
|
|||||||
safeRegisterElement('page-container', PageContainer)
|
safeRegisterElement('page-container', PageContainer)
|
||||||
safeRegisterElement('email-editor', EmailEditor)
|
safeRegisterElement('email-editor', EmailEditor)
|
||||||
safeRegisterElement('markdown-editor', MarkdownEditor)
|
safeRegisterElement('markdown-editor', MarkdownEditor)
|
||||||
|
safeRegisterElement('html-editor', HtmlEditor)
|
||||||
safeRegisterElement('mount-on-click', MountOnClick)
|
safeRegisterElement('mount-on-click', MountOnClick)
|
||||||
safeRegisterElement('remove-on-event', RemoveOnEvent)
|
safeRegisterElement('remove-on-event', RemoveOnEvent)
|
||||||
safeRegisterElement('scroll-to', ScrollTo)
|
safeRegisterElement('scroll-to', ScrollTo)
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ function loadCodeMirror () {
|
|||||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'),
|
import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'),
|
||||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/language'),
|
import(/* webpackChunkName: "email-editor" */ '@codemirror/language'),
|
||||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'),
|
import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'),
|
||||||
|
import(/* webpackChunkName: "email-editor" */ '@codemirror/lint'),
|
||||||
import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow')
|
import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow')
|
||||||
]).then(([view, commands, language, html, htmlflow]) => {
|
]).then(([view, commands, language, html, lint, htmlflow]) => {
|
||||||
return {
|
return {
|
||||||
minimalSetup: [
|
minimalSetup: [
|
||||||
commands.history(),
|
commands.history(),
|
||||||
@@ -19,6 +20,8 @@ function loadCodeMirror () {
|
|||||||
],
|
],
|
||||||
EditorView: view.EditorView,
|
EditorView: view.EditorView,
|
||||||
html: html.html,
|
html: html.html,
|
||||||
|
htmlLanguage: html.htmlLanguage,
|
||||||
|
linter: lint.linter,
|
||||||
htmlflow: htmlflow.default || htmlflow
|
htmlflow: htmlflow.default || htmlflow
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -46,6 +49,70 @@ export default targetable(class extends HTMLElement {
|
|||||||
|
|
||||||
this.previewViewTab.addEventListener('click', this.showPreviewView)
|
this.previewViewTab.addEventListener('click', this.showPreviewView)
|
||||||
this.codeViewTab.addEventListener('click', this.showCodeView)
|
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*(<!doctype[^>]*>\s*)?<html/i.test(value)) {
|
||||||
|
diagnostics.push({
|
||||||
|
from: 0,
|
||||||
|
to: Math.min(5, value.length),
|
||||||
|
severity: 'error',
|
||||||
|
message: 'The email template must start with the <html> 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 = () => {
|
showCodeView = () => {
|
||||||
@@ -76,7 +143,9 @@ export default targetable(class extends HTMLElement {
|
|||||||
this.input = this.querySelector('input[type="hidden"]')
|
this.input = this.querySelector('input[type="hidden"]')
|
||||||
this.input.style.display = 'none'
|
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({
|
this.editorView = new EditorView({
|
||||||
doc: this.input.value,
|
doc: this.input.value,
|
||||||
@@ -85,8 +154,11 @@ export default targetable(class extends HTMLElement {
|
|||||||
html(),
|
html(),
|
||||||
minimalSetup,
|
minimalSetup,
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
|
linter((view) => this.buildDiagnostics(view.state.doc.toString()), { delay: 600 }),
|
||||||
EditorView.updateListener.of(update => {
|
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({
|
EditorView.theme({
|
||||||
'&': {
|
'&': {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -35,7 +35,7 @@ function loadTiptap () {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
class LinkTooltip {
|
export class LinkTooltip {
|
||||||
constructor (container, editor, templateEl) {
|
constructor (container, editor, templateEl) {
|
||||||
this.container = container
|
this.container = container
|
||||||
this.editor = editor
|
this.editor = editor
|
||||||
|
|||||||
@@ -830,6 +830,7 @@ export default {
|
|||||||
} else if (format === 'percent') {
|
} else if (format === 'percent') {
|
||||||
return `${number}%`
|
return `${number}%`
|
||||||
} else if (format === 'percent_space') {
|
} else if (format === 'percent_space') {
|
||||||
|
// eslint-disable-next-line no-irregular-whitespace
|
||||||
return `${String(number).replace('.', ',')} %`
|
return `${String(number).replace('.', ',')} %`
|
||||||
} else {
|
} else {
|
||||||
return number
|
return number
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<div class="flex items-center px-2 py-2 border-b" style="height: 42px;">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('bold') %> (Ctrl+B)">
|
||||||
|
<button type="button" data-action="click:<%= editor_tag %>#bold" data-target="<%= editor_tag %>.boldButton" aria-label="<%= t('bold') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||||
|
<%= svg_icon('bold', class: 'w-4 h-4') %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('italic') %> (Ctrl+I)">
|
||||||
|
<button type="button" data-action="click:<%= editor_tag %>#italic" data-target="<%= editor_tag %>.italicButton" aria-label="<%= t('italic') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||||
|
<%= svg_icon('italic', class: 'w-4 h-4') %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('underline') %> (Ctrl+U)">
|
||||||
|
<button type="button" data-action="click:<%= editor_tag %>#underline" data-target="<%= editor_tag %>.underlineButton" aria-label="<%= t('underline') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||||
|
<%= svg_icon('underline', class: 'w-4 h-4') %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('link') %> (Ctrl+K)">
|
||||||
|
<button type="button" data-action="click:<%= editor_tag %>#linkSelection" data-target="<%= editor_tag %>.linkButton" aria-label="<%= t('link') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||||
|
<%= svg_icon('link', class: 'w-4 h-4') %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mx-2 h-5 border-l border-base-content/20"></div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('undo') %> (Ctrl+Z)">
|
||||||
|
<button type="button" data-action="click:<%= editor_tag %>#undo" data-target="<%= editor_tag %>.undoButton" aria-label="<%= t('undo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||||
|
<%= svg_icon('arrow_back_up', class: 'w-4 h-4') %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('redo') %> (Ctrl+Shift+Z)">
|
||||||
|
<button type="button" data-action="click:<%= editor_tag %>#redo" data-target="<%= editor_tag %>.redoButton" aria-label="<%= t('redo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||||
|
<%= svg_icon('arrow_forward_up', class: 'w-4 h-4') %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% 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') } %>
|
||||||
|
<div class="dropdown dropdown-end ml-auto">
|
||||||
|
<label tabindex="0" class="flex items-center gap-1 text-sm px-2 py-1 rounded hover:bg-base-200 cursor-pointer">
|
||||||
|
<%= t('add_variable') %>
|
||||||
|
<%= svg_icon('chevron_down', class: 'w-3.5 h-3.5') %>
|
||||||
|
</label>
|
||||||
|
<div tabindex="0" class="dropdown-content right-0 top-full mt-1 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50">
|
||||||
|
<% local_assigns[:variables]&.each do |variable| %>
|
||||||
|
<button type="button" data-variable="<%= variable %>" data-action="click:<%= editor_tag %>#insertVariable" class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 text-left text-sm cursor-pointer whitespace-nowrap">
|
||||||
|
<%= variable_labels.fetch(variable, "{#{variable}}") %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<%= render 'personalization_settings/markdown_editor', name:, value:, variables: local_assigns[:variables] %>
|
||||||
@@ -1,76 +1,18 @@
|
|||||||
<% if value.to_s.start_with?('<html') %>
|
<markdown-editor>
|
||||||
<autoresize-textarea>
|
<template data-target="markdown-editor.linkTooltipTemplate">
|
||||||
<%= text_area_tag name, value, required: true, class: 'base-input w-full py-2 !rounded-2xl', dir: 'auto', style: 'max-height: 400px' %>
|
<div class="hidden absolute flex bg-white border border-base-300 rounded-xl shadow p-1 gap-1 items-center z-50" contenteditable="false">
|
||||||
</autoresize-textarea>
|
<input type="text" placeholder="<%= t('enter_a_url_or_variable_name') %>" class="rounded-lg border border-base-300 px-2 py-1 text-sm outline-none" style="field-sizing: content; min-width: 205px; max-width: 320px;" autocomplete="off">
|
||||||
<% else %>
|
<button type="button" data-role="link-save" class="flex items-center px-1 w-6 h-6 rounded hover:bg-success/10 cursor-pointer">
|
||||||
<markdown-editor>
|
<%= svg_icon('check', class: 'w-4 h-4 text-success') %>
|
||||||
<template data-target="markdown-editor.linkTooltipTemplate">
|
</button>
|
||||||
<div class="hidden absolute flex bg-white border border-base-300 rounded-xl shadow p-1 gap-1 items-center z-50" contenteditable="false">
|
<button type="button" data-role="link-remove" class="flex items-center px-1 w-6 h-6 rounded hover:bg-error/10 cursor-pointer">
|
||||||
<input type="text" placeholder="<%= t('enter_a_url_or_variable_name') %>" class="rounded-lg border border-base-300 px-2 py-1 text-sm outline-none" style="field-sizing: content; min-width: 205px; max-width: 320px;" autocomplete="off">
|
<%= svg_icon('x', class: 'w-4 h-4 text-error') %>
|
||||||
<button type="button" data-role="link-save" class="flex items-center px-1 w-6 h-6 rounded hover:bg-success/10 cursor-pointer">
|
</button>
|
||||||
<%= svg_icon('check', class: 'w-4 h-4 text-success') %>
|
|
||||||
</button>
|
|
||||||
<button type="button" data-role="link-remove" class="flex items-center px-1 w-6 h-6 rounded hover:bg-error/10 cursor-pointer">
|
|
||||||
<%= svg_icon('x', class: 'w-4 h-4 text-error') %>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="border border-base-content/20 rounded-2xl bg-white">
|
|
||||||
<div class="flex items-center px-2 py-2 border-b" style="height: 42px;">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('bold') %> (Ctrl+B)">
|
|
||||||
<button type="button" data-action="click:markdown-editor#bold" data-target="markdown-editor.boldButton" aria-label="<%= t('bold') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
|
||||||
<%= svg_icon('bold', class: 'w-4 h-4') %>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('italic') %> (Ctrl+I)">
|
|
||||||
<button type="button" data-action="click:markdown-editor#italic" data-target="markdown-editor.italicButton" aria-label="<%= t('italic') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
|
||||||
<%= svg_icon('italic', class: 'w-4 h-4') %>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('underline') %> (Ctrl+U)">
|
|
||||||
<button type="button" data-action="click:markdown-editor#underline" data-target="markdown-editor.underlineButton" aria-label="<%= t('underline') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
|
||||||
<%= svg_icon('underline', class: 'w-4 h-4') %>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('link') %> (Ctrl+K)">
|
|
||||||
<button type="button" data-action="click:markdown-editor#linkSelection" data-target="markdown-editor.linkButton" aria-label="<%= t('link') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
|
||||||
<%= svg_icon('link', class: 'w-4 h-4') %>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mx-2 h-5 border-l border-base-content/20"></div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('undo') %> (Ctrl+Z)">
|
|
||||||
<button type="button" data-action="click:markdown-editor#undo" data-target="markdown-editor.undoButton" aria-label="<%= t('undo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
|
||||||
<%= svg_icon('arrow_back_up', class: 'w-4 h-4') %>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('redo') %> (Ctrl+Shift+Z)">
|
|
||||||
<button type="button" data-action="click:markdown-editor#redo" data-target="markdown-editor.redoButton" aria-label="<%= t('redo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
|
||||||
<%= svg_icon('arrow_forward_up', class: 'w-4 h-4') %>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% 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') } %>
|
|
||||||
<div class="dropdown dropdown-end ml-auto">
|
|
||||||
<label tabindex="0" class="flex items-center gap-1 text-sm px-2 py-1 rounded hover:bg-base-200 cursor-pointer">
|
|
||||||
<%= t('add_variable') %>
|
|
||||||
<%= svg_icon('chevron_down', class: 'w-3.5 h-3.5') %>
|
|
||||||
</label>
|
|
||||||
<div tabindex="0" class="dropdown-content right-0 top-full mt-1 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50">
|
|
||||||
<% local_assigns[:variables]&.each do |variable| %>
|
|
||||||
<button type="button" data-variable="<%= variable %>" data-action="click:markdown-editor#insertVariable" class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 text-left text-sm cursor-pointer whitespace-nowrap">
|
|
||||||
<%= variable_labels.fetch(variable, "{#{variable}}") %>
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<div data-target="markdown-editor.editorElement"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<%= hidden_field_tag name, value, required: true, data: { target: 'markdown-editor.textarea' } %>
|
</template>
|
||||||
</markdown-editor>
|
<div class="border border-base-content/20 rounded-2xl bg-white">
|
||||||
<% end %>
|
<%= render 'personalization_settings/editor_toolbar', editor_tag: 'markdown-editor', variables: local_assigns[:variables] %>
|
||||||
|
<div data-target="markdown-editor.editorElement"></div>
|
||||||
|
</div>
|
||||||
|
<%= hidden_field_tag name, value, required: true, data: { target: 'markdown-editor.textarea' } %>
|
||||||
|
</markdown-editor>
|
||||||
|
|||||||
@@ -39,10 +39,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %>
|
<% 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) %>
|
<% 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_subject = template&.preferences&.dig('invitation_view_email_subject').presence %>
|
||||||
<% view_template_body = template&.preferences&.dig('invitation_view_email_body').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_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) %>
|
<% is_edit_viewer = local_assigns[:submitter] && local_assigns[:viewer_submitter_uuids].include?(local_assigns[:submitter].uuid) %>
|
||||||
<div id="<%= message_field_id %>" class="card card-compact bg-base-300/40 hidden">
|
<div id="<%= message_field_id %>" class="card card-compact bg-base-300/40 hidden">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -67,8 +68,8 @@
|
|||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<%= f.label :message, t('body'), class: 'label' %>
|
<%= f.label :message, t('body'), class: 'label' %>
|
||||||
<% body_variables = AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %>
|
<% body_variables = AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %>
|
||||||
<%= render 'personalization_settings/markdown_editor', name: f.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || (is_edit_viewer ? view_template_body : nil) || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || (is_edit_viewer ? view_config.value['body'] : default_body), variables: body_variables %>
|
<%= render 'personalization_settings/email_body_editor', name: f.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || (is_edit_viewer ? view_template_body : nil) || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || (is_edit_viewer ? view_config.value['body'] : default_body), variables: body_variables %>
|
||||||
<% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
|
<% if !local_assigns.fetch(:disable_save_as_default_template_option, false) && config.value['body_type'] != 'html' %>
|
||||||
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
|
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
|
||||||
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
|
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
|
||||||
<span class="label"><%= t('save_as_default_template_message') %></span>
|
<span class="label"><%= t('save_as_default_template_message') %></span>
|
||||||
@@ -103,7 +104,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<%= ff.label :message, t('body'), class: 'label' %>
|
<%= ff.label :message, t('body'), class: 'label' %>
|
||||||
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || (is_viewer ? view_template_body : nil) || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || (is_viewer ? view_config.value['body'] : default_body), variables: body_variables %>
|
<%= render 'personalization_settings/email_body_editor', name: ff.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || (is_viewer ? view_template_body : nil) || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || (is_viewer ? view_config.value['body'] : default_body), variables: body_variables %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<%= ff.label :completed_notification_email_body, t('email_body'), class: 'label' %>
|
<%= 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 %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<%= ff.label :documents_copy_email_body, t('email_body'), class: 'label' %>
|
<%= 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 %>
|
||||||
</div>
|
</div>
|
||||||
<% if can?(:manage, :reply_to) %>
|
<% if can?(:manage, :reply_to) %>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<%= ff.label :request_email_body, t('email_body'), class: 'label' %>
|
<%= 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 %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><%= t('email_body') %></label>
|
<label class="label"><%= t('email_body') %></label>
|
||||||
<%= 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 %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<%= ff.label :invitation_view_email_body, t('email_body'), class: 'label' %>
|
<%= 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 %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ module EmailMessages
|
|||||||
ASSET_REGEXP = Regexp.union(STYLE_REGEXP, BASE64_REGEXP)
|
ASSET_REGEXP = Regexp.union(STYLE_REGEXP, BASE64_REGEXP)
|
||||||
ASSET_PREFIX = '[[asset:'
|
ASSET_PREFIX = '[[asset:'
|
||||||
PLACEHOLDER_REGEXP = /\[\[asset:(\h{40})\]\]/
|
PLACEHOLDER_REGEXP = /\[\[asset:(\h{40})\]\]/
|
||||||
|
HTML_MIME_TYPES = ['text/html', 'application/xhtml+xml'].freeze
|
||||||
|
|
||||||
module_function
|
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)
|
def find_or_create_for_account_user(account, user, subject, body)
|
||||||
subject = I18n.t(:you_are_invited_to_sign_a_document) if subject.blank?
|
subject = I18n.t(:you_are_invited_to_sign_a_document) if subject.blank?
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user