diff --git a/app/javascript/application.scss b/app/javascript/application.scss index 47eeb4f6..fd02cecf 100644 --- a/app/javascript/application.scss +++ b/app/javascript/application.scss @@ -155,3 +155,7 @@ button[disabled] .enabled, button.btn-disabled .enabled { .font-courier { font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco; } + +markdown-editor [contenteditable] p { + margin-bottom: 18px; +} diff --git a/app/javascript/elements/markdown_editor.js b/app/javascript/elements/markdown_editor.js index 010100e7..c619ebfc 100644 --- a/app/javascript/elements/markdown_editor.js +++ b/app/javascript/elements/markdown_editor.js @@ -1,4 +1,5 @@ import { target, targetable } from '@github/catalyst/lib/targetable' +import { actionable } from '@github/catalyst/lib/actionable' function loadTiptap () { return Promise.all([ @@ -35,78 +36,63 @@ function loadTiptap () { } class LinkTooltip { - constructor (tooltip, input, saveButton, removeButton, normalizeUrlFn) { + constructor (tooltip, input, saveButton, removeButton, editor) { 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 + this.editor = editor } - updatePosition () { - const rect = this.targetElement.getBoundingClientRect() - this.tooltip.style.left = `${rect.left}px` - this.tooltip.style.top = `${rect.bottom + 5}px` + isVisible () { + return !this.tooltip.classList.contains('hidden') } - setupClickOutside () { - this.closeOnClickOutside = (e) => { - if (!this.tooltip.contains(e.target)) { - this.hide() - } - } + updatePosition () { + const rect = this.editor.view.coordsAtPos(this.pos) - this.clickOutsideTimeout = setTimeout(() => { - if (this.closeOnClickOutside) { - document.addEventListener('click', this.closeOnClickOutside) - } - }, 100) + this.tooltip.style.left = `${rect.left}px` + this.tooltip.style.top = `${rect.bottom + 6}px` } - setupScrollTracking () { - this.scrollHandler = () => this.updatePosition() - window.addEventListener('scroll', this.scrollHandler, true) - } + 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 - show ({ url, targetElement, onSave, onRemove }) { - this.hide() + return `https://${url}` + } + show (url, pos) { this.input.value = url || '' this.removeButton.classList.toggle('hidden', !url) - this.targetElement = targetElement - - this.updatePosition() + this.pos = pos this.tooltip.classList.remove('hidden') - this.input.focus() - this.input.select() - const save = () => { - const inputUrl = this.input.value.trim() + this.updatePosition() - this.hide() + this.saveHandler = () => { + const inputUrl = this.input.value.trim() if (inputUrl) { - onSave(this.normalizeUrl(inputUrl)) + this.editor.chain().focus().extendMarkRange('link').setLink({ href: this.normalizeUrl(inputUrl) }).run() } + + this.hide() } - this.saveHandler = () => save() this.removeHandler = () => { - if (onRemove) onRemove() + this.editor.chain().focus().extendMarkRange('link').unsetLink().run() + this.hide() } + this.keyHandler = (e) => { if (e.key === 'Enter') { e.preventDefault() - save() + this.saveHandler() } else if (e.key === 'Escape') { e.preventDefault() this.hide() @@ -117,26 +103,16 @@ class LinkTooltip { this.removeButton.addEventListener('click', this.removeHandler, { once: true }) this.input.addEventListener('keydown', this.keyHandler) - this.setupScrollTracking() - this.setupClickOutside() + this.scrollHandler = () => this.updatePosition() + window.addEventListener('scroll', this.scrollHandler, true) } 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 @@ -153,16 +129,14 @@ class LinkTooltip { } this.tooltip?.classList.add('hidden') - this.targetElement = null + this.currentMark = null } } -export default targetable(class extends HTMLElement { +export default actionable(targetable(class extends HTMLElement { static [target.static] = [ 'textarea', 'editorElement', - 'variableButton', - 'variableDropdown', 'boldButton', 'italicButton', 'underlineButton', @@ -181,14 +155,6 @@ export default targetable(class extends HTMLElement { 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) => { @@ -245,12 +211,20 @@ export default targetable(class extends HTMLElement { HardBreak, UndoRedo, Link.extend({ - inclusive: false + inclusive: true, + addKeyboardShortcuts: () => ({ + 'Mod-k': () => { + this.toggleLink() + + return true + } + }) }).configure({ openOnClick: false, HTMLAttributes: { class: 'link', - style: 'color: #2563eb; text-decoration: underline; cursor: pointer;' + 'data-turbo': 'false', + style: 'color: #2563eb; text-decoration: underline; cursor: text;' } }), Underline, @@ -260,58 +234,36 @@ export default targetable(class extends HTMLElement { 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 + style: 'min-height: 220px', + class: 'p-3 outline-none focus:outline-none' } }, onUpdate: ({ editor }) => { this.textarea.value = editor.getMarkdown() this.textarea.dispatchEvent(new Event('input', { bubbles: true })) }, - onSelectionUpdate: () => { + onSelectionUpdate: ({ editor }) => { this.updateToolbarState() + this.handleLinkTooltip(editor) + }, + onBlur: () => { + setTimeout(() => { + if (!this.linkTooltipElement.contains(document.activeElement)) { + this.linkTooltip.hide() + } + }, 0) } }) - 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') - } - }) + this.linkTooltip = new LinkTooltip( + this.linkTooltipElement, + this.linkInput, + this.linkSaveButton, + this.linkRemoveButton, + this.editor + ) - document.addEventListener('click', (e) => { - if (!this.variableButton.contains(e.target) && !this.variableDropdown.contains(e.target)) { - this.variableDropdown.classList.add('hidden') - } - }) - } + this.setupToolbar() } adjustShortcutsForPlatform () { @@ -368,36 +320,61 @@ export default targetable(class extends HTMLElement { 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 + handleLinkTooltip (editor) { + const { from } = editor.state.selection + const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link') - return `https://${url}` + if (!mark) { + if (this.linkTooltip.isVisible()) this.linkTooltip.hide() + + 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 + } + } + + if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return + + this.linkTooltip.hide() + + this.linkTooltip.show(mark.attrs.href, linkStart > start ? linkStart - 1 : linkStart) + + this.linkTooltip.currentMark = mark } toggleLink () { - const { from } = this.editor.state.selection + 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 - const rect = this.editor.view.coordsAtPos(from) - const fakeElement = { getBoundingClientRect: () => rect } + this.linkTooltip.hide() + this.linkTooltip.show(this.editor.getAttributes('link').href, from) + } + } - const previousUrl = this.editor.getAttributes('link').href + insertVariable (e) { + const variable = e.target.closest('[data-variable]')?.dataset.variable - 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 - }) - } + if (variable) { + const { from, to } = this.editor.state.selection - insertVariable (variable) { - this.editor.chain().focus().insertContent(`{${variable}}`).run() + if (variable.includes('link') && from !== to) { + this.editor.chain().focus().setLink({ href: `{${variable}}` }).run() + } else { + this.editor.chain().focus().insertContent(`{${variable}}`).run() + } + } } disconnectedCallback () { @@ -408,4 +385,4 @@ export default targetable(class extends HTMLElement { this.editor = null } } -}) +})) diff --git a/app/views/personalization_settings/_markdown_editor.html.erb b/app/views/personalization_settings/_markdown_editor.html.erb index 45b9a9ed..e1762bb8 100644 --- a/app/views/personalization_settings/_markdown_editor.html.erb +++ b/app/views/personalization_settings/_markdown_editor.html.erb @@ -37,15 +37,15 @@ <% 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') } %> -