add markdown editor

This commit is contained in:
Alex Turchyn
2026-02-21 11:05:45 +02:00
committed by Pete Matsyburka
parent 451f83421a
commit 39cc82ce0c
14 changed files with 1048 additions and 34 deletions
+2
View File
@@ -40,6 +40,7 @@ import DashboardDropzone from './elements/dashboard_dropzone'
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 MountOnClick from './elements/mount_on_click'
import RemoveOnEvent from './elements/remove_on_event'
import ScrollTo from './elements/scroll_to'
@@ -131,6 +132,7 @@ safeRegisterElement('check-on-click', CheckOnClick)
safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('email-editor', EmailEditor)
safeRegisterElement('markdown-editor', MarkdownEditor)
safeRegisterElement('mount-on-click', MountOnClick)
safeRegisterElement('remove-on-event', RemoveOnEvent)
safeRegisterElement('scroll-to', ScrollTo)
+411
View File
@@ -0,0 +1,411 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
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 (tooltip, input, saveButton, removeButton, normalizeUrlFn) {
this.tooltip = tooltip
this.input = input
this.saveButton = saveButton
this.removeButton = removeButton
this.normalizeUrl = normalizeUrlFn
this.targetElement = null
this.clickOutsideTimeout = null
this.closeOnClickOutside = null
this.scrollHandler = null
this.saveHandler = null
this.removeHandler = null
this.keyHandler = null
}
updatePosition () {
const rect = this.targetElement.getBoundingClientRect()
this.tooltip.style.left = `${rect.left}px`
this.tooltip.style.top = `${rect.bottom + 5}px`
}
setupClickOutside () {
this.closeOnClickOutside = (e) => {
if (!this.tooltip.contains(e.target)) {
this.hide()
}
}
this.clickOutsideTimeout = setTimeout(() => {
if (this.closeOnClickOutside) {
document.addEventListener('click', this.closeOnClickOutside)
}
}, 100)
}
setupScrollTracking () {
this.scrollHandler = () => this.updatePosition()
window.addEventListener('scroll', this.scrollHandler, true)
}
show ({ url, targetElement, onSave, onRemove }) {
this.hide()
this.input.value = url || ''
this.removeButton.classList.toggle('hidden', !url)
this.targetElement = targetElement
this.updatePosition()
this.tooltip.classList.remove('hidden')
this.input.focus()
this.input.select()
const save = () => {
const inputUrl = this.input.value.trim()
this.hide()
if (inputUrl) {
onSave(this.normalizeUrl(inputUrl))
}
}
this.saveHandler = () => save()
this.removeHandler = () => {
if (onRemove) onRemove()
this.hide()
}
this.keyHandler = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
save()
} 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)
this.setupScrollTracking()
this.setupClickOutside()
}
hide () {
if (this.clickOutsideTimeout) {
clearTimeout(this.clickOutsideTimeout)
this.clickOutsideTimeout = null
}
if (this.scrollHandler) {
window.removeEventListener('scroll', this.scrollHandler, true)
this.scrollHandler = null
}
if (this.closeOnClickOutside) {
document.removeEventListener('click', this.closeOnClickOutside)
this.closeOnClickOutside = null
}
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.targetElement = null
}
}
export default targetable(class extends HTMLElement {
static [target.static] = [
'textarea',
'editorElement',
'variableButton',
'variableDropdown',
'boldButton',
'italicButton',
'underlineButton',
'linkButton',
'undoButton',
'redoButton',
'linkTooltipElement',
'linkInput',
'linkSaveButton',
'linkRemoveButton'
]
async connectedCallback () {
if (!this.textarea || !this.editorElement) return
this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform()
this.linkTooltip = new LinkTooltip(
this.linkTooltipElement,
this.linkInput,
this.linkSaveButton,
this.linkRemoveButton,
(url) => this.normalizeUrl(url)
)
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,
UndoRedo,
Link.extend({
inclusive: false
}).configure({
openOnClick: false,
HTMLAttributes: {
class: 'link',
style: 'color: #2563eb; text-decoration: underline; cursor: pointer;'
}
}),
Underline,
VariableHighlight
],
content: this.textarea.value || '',
contentType: 'markdown',
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none p-3 outline-none focus:outline-none min-h-[120px]'
},
handleClick: (view, pos, event) => {
const clickedPos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!clickedPos) return false
const linkMark = view.state.doc.resolve(clickedPos.pos).marks().find(m => m.type.name === 'link')
if (linkMark) {
event.preventDefault()
this.editor.chain().setTextSelection(clickedPos.pos).extendMarkRange('link').run()
this.toggleLink()
return true
}
return false
}
},
onUpdate: ({ editor }) => {
this.textarea.value = editor.getMarkdown()
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
},
onSelectionUpdate: () => {
this.updateToolbarState()
}
})
this.setupToolbar()
if (this.variableButton) {
this.variableButton.addEventListener('click', () => {
this.variableDropdown.classList.toggle('hidden')
})
this.variableDropdown.addEventListener('click', (e) => {
const variable = e.target.closest('[data-variable]')?.dataset.variable
if (variable) {
this.insertVariable(variable)
this.variableDropdown.classList.add('hidden')
}
})
document.addEventListener('click', (e) => {
if (!this.variableButton.contains(e.target) && !this.variableDropdown.contains(e.target)) {
this.variableDropdown.classList.add('hidden')
}
})
}
}
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, '⌘'))
}
})
}
}
setupToolbar () {
this.boldButton?.addEventListener('click', (e) => {
e.preventDefault()
this.editor.chain().focus().toggleBold().run()
this.updateToolbarState()
})
this.italicButton?.addEventListener('click', (e) => {
e.preventDefault()
this.editor.chain().focus().toggleItalic().run()
this.updateToolbarState()
})
this.underlineButton?.addEventListener('click', (e) => {
e.preventDefault()
this.editor.chain().focus().toggleUnderline().run()
this.updateToolbarState()
})
this.linkButton?.addEventListener('click', (e) => {
e.preventDefault()
this.toggleLink()
})
this.undoButton?.addEventListener('click', (e) => {
e.preventDefault()
this.editor.chain().focus().undo().run()
})
this.redoButton?.addEventListener('click', (e) => {
e.preventDefault()
this.editor.chain().focus().redo().run()
})
}
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'))
}
normalizeUrl (url) {
if (!url) return url
if (/^https?:\/\//i.test(url)) return url
if (/^mailto:/i.test(url)) return url
return `https://${url}`
}
toggleLink () {
const { from } = this.editor.state.selection
const rect = this.editor.view.coordsAtPos(from)
const fakeElement = { getBoundingClientRect: () => rect }
const previousUrl = this.editor.getAttributes('link').href
this.linkTooltip.show({
url: previousUrl,
targetElement: fakeElement,
onSave: (url) => {
this.editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
},
onRemove: previousUrl
? () => { this.editor.chain().focus().extendMarkRange('link').unsetLink().run() }
: null
})
}
insertVariable (variable) {
this.editor.chain().focus().insertContent(`{${variable}}`).run()
}
disconnectedCallback () {
this.linkTooltip.hide()
if (this.editor) {
this.editor.destroy()
this.editor = null
}
}
})
+5
View File
@@ -0,0 +1,5 @@
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 14l-4 -4l4 -4" />
<path d="M5 10h11a4 4 0 1 1 0 8h-1" />
</svg>

After

Width:  |  Height:  |  Size: 354 B

@@ -0,0 +1,5 @@
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15 14l4 -4l-4 -4" />
<path d="M19 10h-11a4 4 0 1 0 0 8h1" />
</svg>

After

Width:  |  Height:  |  Size: 356 B

+5
View File
@@ -0,0 +1,5 @@
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 5h6a3.5 3.5 0 0 1 0 7h-6l0 -7" />
<path d="M13 12h1a3.5 3.5 0 0 1 0 7h-7v-7" />
</svg>

After

Width:  |  Height:  |  Size: 377 B

+6
View File
@@ -0,0 +1,6 @@
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M11 5l6 0" />
<path d="M7 19l6 0" />
<path d="M14 5l-4 14" />
</svg>

After

Width:  |  Height:  |  Size: 358 B

+5
View File
@@ -0,0 +1,5 @@
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 5v5a5 5 0 0 0 10 0v-5" />
<path d="M5 19h14" />
</svg>

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::DEFAULT_VALUES[local_assigns[:config].key].call['body'].scan(/\{([^}]+)\}/).flatten.uniq %>
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:body), value: local_assigns[:config].value['body'], variables: variables %>
</div>
@@ -0,0 +1,68 @@
<markdown-editor>
<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" data-tip="<%= t('bold') %> (Ctrl+B)">
<button type="button" 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" data-tip="<%= t('italic') %> (Ctrl+I)">
<button type="button" 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" data-tip="<%= t('underline') %> (Ctrl+U)">
<button type="button" 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" data-tip="<%= t('link') %> (Ctrl+K)">
<button type="button" 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" data-tip="<%= t('undo') %> (Ctrl+Z)">
<button type="button" 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" data-tip="<%= t('redo') %> (Ctrl+Shift+Z)">
<button type="button" 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'), 'code' => t('variables.code'), '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="ml-auto relative">
<button type="button" data-target="markdown-editor.variableButton" 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') %>
</button>
<div data-target="markdown-editor.variableDropdown" class="hidden absolute 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 %>" 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 data-target="markdown-editor.linkTooltipElement" class="hidden fixed flex bg-white border border-base-300 rounded-xl shadow-lg p-1 gap-1 items-center z-50">
<input data-target="markdown-editor.linkInput" type="text" placeholder="<%= t('enter_a_url_or_variable_name') %>" class="rounded-lg border border-base-300 px-2 py-1 w-80 text-sm outline-none" autocomplete="off">
<button type="button" data-target="markdown-editor.linkSaveButton" 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-target="markdown-editor.linkRemoveButton" 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>
<%= hidden_field_tag name, value, required: true, data: { target: 'markdown-editor.textarea' } %>
</markdown-editor>
@@ -55,15 +55,9 @@
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
</div>
<div class="form-control">
<div class="flex items-center">
<%= f.label :message, t('body'), class: 'label' %>
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</div>
<autoresize-textarea>
<%= f.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
</autoresize-textarea>
<%= f.label :message, t('body'), class: 'label' %>
<% body_variables = AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/\{([^}]+)\}/).flatten.uniq %>
<%= render 'personalization_settings/markdown_editor', name: f.field_name(:body), value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %>
<% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
@@ -97,15 +91,8 @@
<%= ff.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
</div>
<div class="form-control">
<div class="flex items-center">
<%= ff.label :message, t('body'), class: 'label' %>
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</div>
<autoresize-textarea>
<%= ff.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
</autoresize-textarea>
<%= ff.label :message, t('body'), class: 'label' %>
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:body), value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %>
</div>
</div>
<% end %>