mirror of https://github.com/docusealco/docuseal
Compare commits
34 Commits
33ca930055
...
13fa87c449
| Author | SHA1 | Date |
|---|---|---|
|
|
13fa87c449 | 4 weeks ago |
|
|
246ccfb049 | 4 weeks ago |
|
|
cbffb1e5b8 | 4 weeks ago |
|
|
c4b007411d | 4 weeks ago |
|
|
04264fe937 | 4 weeks ago |
|
|
0c37ad12b7 | 4 weeks ago |
|
|
8c5298ed2e | 4 weeks ago |
|
|
262118e047 | 4 weeks ago |
|
|
e5b63ea2b0 | 4 weeks ago |
|
|
746757d402 | 4 weeks ago |
|
|
e6e640328b | 4 weeks ago |
|
|
845782a69c | 4 weeks ago |
|
|
fc6baa1b3b | 4 weeks ago |
|
|
47822ecc15 | 4 weeks ago |
|
|
3d0c7f1118 | 4 weeks ago |
|
|
e9c8e4d325 | 4 weeks ago |
|
|
39cc82ce0c | 4 weeks ago |
|
|
451f83421a | 4 weeks ago |
|
|
5073f2eefb | 4 weeks ago |
|
|
231fff5508 | 4 weeks ago |
|
|
f1d146eca3 | 4 weeks ago |
|
|
b6635fcc4f | 4 weeks ago |
|
|
fb5e13ee4c | 4 weeks ago |
|
|
ba84741a64 | 4 weeks ago |
|
|
65c275ac17 | 4 weeks ago |
|
|
825322d489 | 4 weeks ago |
|
|
739e2abdf8 | 1 month ago |
|
|
1d2394e31e | 1 month ago |
|
|
1b41af798d | 1 month ago |
|
|
118f4a231b | 1 month ago |
|
|
e48652f425 | 1 month ago |
|
|
848f01edf8 | 1 month ago |
|
|
43fbc42770 | 1 month ago |
|
|
2c736a0eed | 1 month ago |
@ -0,0 +1,385 @@
|
||||
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||
import { actionable } from '@github/catalyst/lib/actionable'
|
||||
|
||||
function loadTiptap () {
|
||||
return Promise.all([
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-bold'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-italic'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-paragraph'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-link'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-underline'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/markdown'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
|
||||
]).then(([core, bold, italic, paragraph, text, hardBreak, document, link, underline, extensions, markdown, pmState, pmView]) => ({
|
||||
Editor: core.Editor,
|
||||
Extension: core.Extension,
|
||||
Bold: bold.default || bold,
|
||||
Italic: italic.default || italic,
|
||||
Paragraph: paragraph.default || paragraph,
|
||||
Text: text.default || text,
|
||||
HardBreak: hardBreak.default || hardBreak,
|
||||
Document: document.default || document,
|
||||
Link: link.default || link,
|
||||
Underline: underline.default || underline,
|
||||
UndoRedo: extensions.UndoRedo,
|
||||
Markdown: markdown.Markdown,
|
||||
Plugin: pmState.Plugin,
|
||||
Decoration: pmView.Decoration,
|
||||
DecorationSet: pmView.DecorationSet
|
||||
}))
|
||||
}
|
||||
|
||||
class LinkTooltip {
|
||||
constructor (container, editor) {
|
||||
this.container = container
|
||||
this.editor = editor
|
||||
|
||||
const template = document.createElement('template')
|
||||
|
||||
template.innerHTML = container.dataset.linkTooltipHtml
|
||||
|
||||
this.tooltip = template.content.firstElementChild
|
||||
|
||||
this.input = this.tooltip.querySelector('input')
|
||||
this.saveButton = this.tooltip.querySelector('[data-role="link-save"]')
|
||||
this.removeButton = this.tooltip.querySelector('[data-role="link-remove"]')
|
||||
|
||||
container.style.position = 'relative'
|
||||
container.appendChild(this.tooltip)
|
||||
}
|
||||
|
||||
isVisible () {
|
||||
return !this.tooltip.classList.contains('hidden')
|
||||
}
|
||||
|
||||
normalizeUrl (url) {
|
||||
if (!url) return url
|
||||
if (/^{/i.test(url)) return url
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
if (/^mailto:/i.test(url)) return url
|
||||
|
||||
return `https://${url}`
|
||||
}
|
||||
|
||||
show (url, pos, { focus = false } = {}) {
|
||||
this.input.value = url || ''
|
||||
this.removeButton.classList.toggle('hidden', !url)
|
||||
|
||||
this.tooltip.classList.remove('hidden')
|
||||
|
||||
const coords = this.editor.view.coordsAtPos(pos)
|
||||
const containerRect = this.container.getBoundingClientRect()
|
||||
|
||||
this.tooltip.style.left = `${coords.left - containerRect.left}px`
|
||||
this.tooltip.style.top = `${coords.bottom - containerRect.top + 4}px`
|
||||
|
||||
if (focus) this.input.focus()
|
||||
|
||||
this.saveHandler = () => {
|
||||
const inputUrl = this.input.value.trim()
|
||||
|
||||
if (inputUrl) {
|
||||
this.editor.chain().focus().extendMarkRange('link').setLink({ href: this.normalizeUrl(inputUrl) }).run()
|
||||
}
|
||||
|
||||
this.hide()
|
||||
}
|
||||
|
||||
this.removeHandler = () => {
|
||||
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
|
||||
this.hide()
|
||||
}
|
||||
|
||||
this.keyHandler = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
this.saveHandler()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
this.hide()
|
||||
}
|
||||
}
|
||||
|
||||
this.saveButton.addEventListener('click', this.saveHandler, { once: true })
|
||||
this.removeButton.addEventListener('click', this.removeHandler, { once: true })
|
||||
this.input.addEventListener('keydown', this.keyHandler)
|
||||
}
|
||||
|
||||
hide () {
|
||||
if (this.saveHandler) {
|
||||
this.saveButton.removeEventListener('click', this.saveHandler)
|
||||
this.saveHandler = null
|
||||
}
|
||||
|
||||
if (this.removeHandler) {
|
||||
this.removeButton.removeEventListener('click', this.removeHandler)
|
||||
this.removeHandler = null
|
||||
}
|
||||
|
||||
if (this.keyHandler) {
|
||||
this.input.removeEventListener('keydown', this.keyHandler)
|
||||
this.keyHandler = null
|
||||
}
|
||||
|
||||
this.tooltip.classList.add('hidden')
|
||||
this.currentMark = null
|
||||
}
|
||||
}
|
||||
|
||||
export default actionable(targetable(class extends HTMLElement {
|
||||
static [target.static] = [
|
||||
'textarea',
|
||||
'editorElement',
|
||||
'boldButton',
|
||||
'italicButton',
|
||||
'underlineButton',
|
||||
'linkButton'
|
||||
]
|
||||
|
||||
async connectedCallback () {
|
||||
if (!this.textarea || !this.editorElement) return
|
||||
|
||||
this.textarea.style.display = 'none'
|
||||
this.adjustShortcutsForPlatform()
|
||||
|
||||
const { Editor, Extension, Bold, Italic, Paragraph, Text, HardBreak, UndoRedo, Document, Link, Underline, Markdown, Plugin, Decoration, DecorationSet } = await loadTiptap()
|
||||
|
||||
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: 'bg-amber-100 py-0.5 px-1 rounded'
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})]
|
||||
}
|
||||
})
|
||||
|
||||
this.editor = new Editor({
|
||||
element: this.editorElement,
|
||||
extensions: [
|
||||
Markdown,
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Bold,
|
||||
Italic,
|
||||
HardBreak.extend({
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
Enter: () => this.editor.commands.setHardBreak()
|
||||
}
|
||||
}
|
||||
}),
|
||||
UndoRedo,
|
||||
Link.extend({
|
||||
inclusive: true,
|
||||
addKeyboardShortcuts: () => ({
|
||||
'Mod-k': () => {
|
||||
this.toggleLink()
|
||||
|
||||
return true
|
||||
}
|
||||
})
|
||||
}).configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'link',
|
||||
'data-turbo': 'false',
|
||||
style: 'color: #2563eb; text-decoration: underline; cursor: text;'
|
||||
}
|
||||
}),
|
||||
Underline,
|
||||
VariableHighlight
|
||||
],
|
||||
content: (this.textarea.value || '').trim().replace(/ *\n/g, '<br>'),
|
||||
contentType: 'markdown',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
style: 'min-height: 220px',
|
||||
dir: 'auto',
|
||||
class: 'p-3 outline-none focus:outline-none'
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
this.textarea.value = editor.getMarkdown()
|
||||
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)
|
||||
}
|
||||
|
||||
adjustShortcutsForPlatform () {
|
||||
if ((navigator.userAgentData?.platform || navigator.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.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').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()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
After Width: | Height: | Size: 354 B |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 377 B |
|
After Width: | Height: | Size: 358 B |
|
After Width: | Height: | Size: 345 B |
@ -1,11 +1,5 @@
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :body, t('body'), class: 'label' %>
|
||||
<span class="tooltip" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[local_assigns[:config].key].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :body, required: true, class: 'base-input w-full !rounded-2xl py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
<%= ff.label :body, t('body'), class: 'label' %>
|
||||
<% variables = AccountConfig::EMAIL_VARIABLES[local_assigns[:config].key] %>
|
||||
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:body), value: local_assigns[:config].value['body'], variables: variables %>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
<% link_tooltip_html = capture do %>
|
||||
<div class="hidden absolute flex bg-white border border-base-300 rounded-xl shadow p-1 gap-1 items-center z-50" contenteditable="false">
|
||||
<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">
|
||||
<button type="button" data-role="link-save" class="flex items-center px-1 w-6 h-6 rounded hover:bg-success/10 cursor-pointer">
|
||||
<%= 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>
|
||||
<% end %>
|
||||
<markdown-editor data-link-tooltip-html="<%= link_tooltip_html.squish %>">
|
||||
<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>
|
||||
<%= hidden_field_tag name, value, required: true, data: { target: 'markdown-editor.textarea' } %>
|
||||
</markdown-editor>
|
||||
@ -1,5 +1,5 @@
|
||||
<% if configs = account.account_configs.find_by(key: AccountConfig::POLICY_LINKS_KEY) %>
|
||||
<div class="max-w-md mx-auto flex flex-wrap gap-1 justify-center text-sm text-base-content/60 mt-2">
|
||||
<%= auto_link(MarkdownToHtml.call(configs.value)) %>
|
||||
<%= MarkdownToHtml.call(configs.value) %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@ -1 +1 @@
|
||||
<%= auto_link(simple_format(MarkdownToHtml.call(h(ReplaceEmailVariables.call(local_assigns[:content], submitter: local_assigns[:submitter], sig: local_assigns[:sig]))))) %>
|
||||
<%= MarkdownToHtml.call(ReplaceEmailVariables.call(local_assigns[:content], submitter: local_assigns[:submitter], sig: local_assigns[:sig])) %>
|
||||
|
||||
@ -1,13 +1,132 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module MarkdownToHtml
|
||||
LINK_REGEXP = %r{\[([^\]]+)\]\((https?://[^)]+)\)}
|
||||
TAGS = {
|
||||
'' => %w[<em> </em>],
|
||||
'*' => %w[<strong> </strong>],
|
||||
'~' => %w[<s> </s>]
|
||||
}.freeze
|
||||
|
||||
INLINE_TOKENIZER = /(\[)|(\]\(([^)]+?)\))|(?:`([^`].*?)`)|(\*\*\*|\*\*|\*|~~)/
|
||||
|
||||
ALLOWED_TAGS = %w[p br strong b em i u a].freeze
|
||||
ALLOWED_ATTRIBUTES = %w[href].freeze
|
||||
|
||||
module_function
|
||||
|
||||
def call(text)
|
||||
text.gsub(LINK_REGEXP) do
|
||||
ApplicationController.helpers.link_to(Regexp.last_match(1), Regexp.last_match(2))
|
||||
def call(markdown)
|
||||
return '' if markdown.blank?
|
||||
|
||||
text = auto_link_urls(markdown)
|
||||
html = render_markdown(text)
|
||||
|
||||
ActionController::Base.helpers.sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
|
||||
end
|
||||
|
||||
BRACKETS = { ')' => '(', ']' => '[', '}' => '{' }.freeze
|
||||
|
||||
def auto_link_urls(text)
|
||||
link_parts = text.split(%r{((?:https?://|www\.)[^\s<>\u00A0"]+)})
|
||||
|
||||
link_parts.map.with_index do |part, index|
|
||||
if part.match?(%r{\A(?:https?://|www\.)}) && !(index > 0 && link_parts[index - 1]&.match?(/\]\(\s*\z/))
|
||||
url_part = part.dup
|
||||
punctuation = []
|
||||
|
||||
while url_part.sub!(%r{[^\p{Word}/\-=;]\z}, '')
|
||||
punctuation.push(::Regexp.last_match(0))
|
||||
|
||||
opening = BRACKETS[punctuation.last]
|
||||
|
||||
next unless opening && url_part.count(opening) > url_part.count(punctuation.last)
|
||||
|
||||
url_part << punctuation.pop
|
||||
|
||||
break
|
||||
end
|
||||
|
||||
trail = punctuation.reverse.join
|
||||
url = url_part.start_with?('www.') ? "https://#{url_part}" : url_part
|
||||
|
||||
"[#{url_part}](#{url})#{trail}"
|
||||
else
|
||||
part
|
||||
end
|
||||
end.join
|
||||
end
|
||||
|
||||
def render_markdown(text)
|
||||
text = text.gsub(/\+\+([^+]+)\+\+/, '<u>\1</u>')
|
||||
|
||||
paragraphs = text.split(/\n{2,}/)
|
||||
|
||||
html = paragraphs.filter_map do |para|
|
||||
content = para.strip
|
||||
|
||||
next if content.empty?
|
||||
|
||||
next '<p><br></p>' if [' ', '&nbsp;'].include?(content)
|
||||
|
||||
content = content.gsub(/ *\n/, '<br>')
|
||||
|
||||
"<p>#{parse_inline(content)}</p>"
|
||||
end.join
|
||||
|
||||
html.presence || '<p></p>'
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def parse_inline(text)
|
||||
context = []
|
||||
out = ''
|
||||
last = 0
|
||||
|
||||
tag = lambda do |t|
|
||||
desc = TAGS[t[1] || '']
|
||||
|
||||
return t unless desc
|
||||
|
||||
is_end = context.last == t
|
||||
is_end ? context.pop : context.push(t)
|
||||
desc[is_end ? 1 : 0]
|
||||
end
|
||||
|
||||
flush = lambda do
|
||||
str = ''
|
||||
str += tag.call(context.last) while context.any?
|
||||
str
|
||||
end
|
||||
|
||||
while last <= text.length && (m = INLINE_TOKENIZER.match(text, last))
|
||||
prev = text[last...m.begin(0)]
|
||||
last = m.end(0)
|
||||
chunk = m[0]
|
||||
|
||||
if m[4]
|
||||
chunk = "<code>#{ERB::Util.html_escape(m[4])}</code>"
|
||||
elsif m[2]
|
||||
out = out.sub(/\A(.*)<a>/m, "\\1<a href=\"#{ERB::Util.html_escape(m[3])}\">")
|
||||
out = out.gsub('<a>', '[')
|
||||
chunk = "#{flush.call}</a>"
|
||||
elsif m[1]
|
||||
chunk = '<a>'
|
||||
elsif m[5]
|
||||
chunk =
|
||||
if m[5] == '***'
|
||||
if context.include?('*') && context.include?('**')
|
||||
tag.call('*') + tag.call('**')
|
||||
else
|
||||
tag.call('**') + tag.call('*')
|
||||
end
|
||||
else
|
||||
tag.call(m[5])
|
||||
end
|
||||
end
|
||||
|
||||
out += prev.to_s + chunk
|
||||
end
|
||||
|
||||
(out + text[last..].to_s + flush.call).gsub('<a>', '[')
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
|
||||
Loading…
Reference in new issue