Files
docuseal/app/javascript/elements/markdown_editor.js
T

383 lines
11 KiB
JavaScript
Raw Normal View History

2026-02-21 11:05:45 +02:00
import { target, targetable } from '@github/catalyst/lib/targetable'
2026-02-21 11:28:46 +02:00
import { actionable } from '@github/catalyst/lib/actionable'
2026-02-21 11:05:45 +02:00
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
}))
}
2026-07-03 02:14:55 +03:00
export class LinkTooltip {
2026-02-24 11:29:32 +02:00
constructor (container, editor, templateEl) {
2026-02-22 09:20:56 +02:00
this.container = container
2026-02-21 11:28:46 +02:00
this.editor = editor
2026-02-22 09:20:56 +02:00
2026-02-24 11:29:32 +02:00
this.tooltip = templateEl.content.firstElementChild.cloneNode(true)
2026-02-22 09:20:56 +02:00
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)
2026-02-21 11:05:45 +02:00
}
2026-02-21 11:28:46 +02:00
isVisible () {
return !this.tooltip.classList.contains('hidden')
2026-02-21 11:05:45 +02:00
}
2026-02-21 11:28:46 +02:00
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
2026-02-21 11:05:45 +02:00
2026-02-21 11:28:46 +02:00
return `https://${url}`
}
2026-02-21 11:05:45 +02:00
2026-02-22 09:20:56 +02:00
show (url, pos, { focus = false } = {}) {
2026-02-21 11:05:45 +02:00
this.input.value = url || ''
this.removeButton.classList.toggle('hidden', !url)
this.tooltip.classList.remove('hidden')
2026-02-22 09:20:56 +02:00
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()
2026-02-21 11:05:45 +02:00
2026-02-21 11:28:46 +02:00
this.saveHandler = () => {
const inputUrl = this.input.value.trim()
2026-02-21 11:05:45 +02:00
if (inputUrl) {
2026-02-21 11:28:46 +02:00
this.editor.chain().focus().extendMarkRange('link').setLink({ href: this.normalizeUrl(inputUrl) }).run()
2026-02-21 11:05:45 +02:00
}
2026-02-21 11:28:46 +02:00
this.hide()
2026-02-21 11:05:45 +02:00
}
this.removeHandler = () => {
2026-02-21 11:28:46 +02:00
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
2026-02-21 11:05:45 +02:00
this.hide()
}
2026-02-21 11:28:46 +02:00
2026-02-21 11:05:45 +02:00
this.keyHandler = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
2026-02-21 11:28:46 +02:00
this.saveHandler()
2026-02-21 11:05:45 +02:00
} 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
}
2026-02-22 09:20:56 +02:00
this.tooltip.classList.add('hidden')
2026-02-21 11:28:46 +02:00
this.currentMark = null
2026-02-21 11:05:45 +02:00
}
}
2026-02-21 11:28:46 +02:00
export default actionable(targetable(class extends HTMLElement {
2026-02-21 11:05:45 +02:00
static [target.static] = [
'textarea',
'editorElement',
'boldButton',
'italicButton',
'underlineButton',
2026-02-24 11:29:32 +02:00
'linkButton',
'linkTooltipTemplate'
2026-02-21 11:05:45 +02:00
]
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 = []
2026-02-23 08:31:38 +02:00
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
2026-02-21 11:05:45 +02:00
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,
2026-02-21 16:20:48 +02:00
HardBreak.extend({
addKeyboardShortcuts () {
return {
Enter: () => this.editor.commands.setHardBreak()
}
}
}),
2026-02-21 11:05:45 +02:00
UndoRedo,
Link.extend({
2026-02-21 11:28:46 +02:00
inclusive: true,
addKeyboardShortcuts: () => ({
'Mod-k': () => {
this.toggleLink()
return true
}
})
2026-02-21 11:05:45 +02:00
}).configure({
openOnClick: false,
HTMLAttributes: {
class: 'link',
2026-02-21 11:28:46 +02:00
'data-turbo': 'false',
style: 'color: #2563eb; text-decoration: underline; cursor: text;'
2026-02-21 11:05:45 +02:00
}
}),
Underline,
VariableHighlight
],
2026-02-21 16:20:48 +02:00
content: (this.textarea.value || '').trim().replace(/ *\n/g, '<br>'),
2026-02-21 11:05:45 +02:00
contentType: 'markdown',
editorProps: {
attributes: {
2026-02-21 11:28:46 +02:00
style: 'min-height: 220px',
2026-02-21 16:38:25 +02:00
dir: 'auto',
2026-02-21 11:28:46 +02:00
class: 'p-3 outline-none focus:outline-none'
2026-02-21 11:05:45 +02:00
}
},
onUpdate: ({ editor }) => {
this.textarea.value = editor.getMarkdown()
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
},
2026-02-21 11:28:46 +02:00
onSelectionUpdate: ({ editor }) => {
2026-02-21 11:05:45 +02:00
this.updateToolbarState()
2026-02-21 11:28:46 +02:00
this.handleLinkTooltip(editor)
},
onBlur: () => {
setTimeout(() => {
2026-02-22 09:20:56 +02:00
if (!this.linkTooltip.tooltip.contains(document.activeElement)) {
2026-02-21 11:28:46 +02:00
this.linkTooltip.hide()
}
}, 0)
2026-02-21 11:05:45 +02:00
}
})
2026-02-24 11:29:32 +02:00
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
2026-02-21 11:05:45 +02:00
}
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, '⌘'))
}
})
}
}
2026-02-22 09:20:56 +02:00
bold (e) {
e.preventDefault()
2026-02-21 11:05:45 +02:00
2026-02-22 09:20:56 +02:00
this.editor.chain().focus().toggleBold().run()
this.updateToolbarState()
}
2026-02-21 11:05:45 +02:00
2026-02-22 09:20:56 +02:00
italic (e) {
e.preventDefault()
2026-02-21 11:05:45 +02:00
2026-02-22 09:20:56 +02:00
this.editor.chain().focus().toggleItalic().run()
this.updateToolbarState()
}
2026-02-21 11:05:45 +02:00
2026-02-22 09:20:56 +02:00
underline (e) {
e.preventDefault()
2026-02-21 11:05:45 +02:00
2026-02-22 09:20:56 +02:00
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()
2026-02-21 11:05:45 +02:00
}
updateToolbarState () {
2026-02-22 09:20:56 +02:00
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'))
2026-02-21 11:05:45 +02:00
}
2026-02-21 11:28:46 +02:00
handleLinkTooltip (editor) {
const { from } = editor.state.selection
const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
2026-02-21 11:05:45 +02:00
2026-02-21 11:28:46 +02:00
if (!mark) {
if (this.linkTooltip.isVisible()) this.linkTooltip.hide()
return
}
2026-02-22 09:20:56 +02:00
if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return
2026-02-21 11:28:46 +02:00
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
2026-02-21 11:05:45 +02:00
}
toggleLink () {
2026-02-21 11:28:46 +02:00
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
2026-02-21 11:05:45 +02:00
2026-02-21 11:28:46 +02:00
this.linkTooltip.hide()
2026-02-22 09:20:56 +02:00
this.linkTooltip.show(this.editor.getAttributes('link').href, from, { focus: true })
2026-02-21 11:28:46 +02:00
}
}
2026-02-21 11:05:45 +02:00
2026-02-21 11:28:46 +02:00
insertVariable (e) {
const variable = e.target.closest('[data-variable]')?.dataset.variable
2026-02-21 11:05:45 +02:00
2026-02-21 11:28:46 +02:00
if (variable) {
const { from, to } = this.editor.state.selection
2026-02-21 11:05:45 +02:00
2026-02-21 11:28:46 +02:00
if (variable.includes('link') && from !== to) {
this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
} else {
this.editor.chain().focus().insertContent(`{${variable}}`).run()
}
}
2026-02-21 11:05:45 +02:00
}
disconnectedCallback () {
this.linkTooltip.hide()
if (this.editor) {
this.editor.destroy()
}
}
2026-02-21 11:28:46 +02:00
}))