adjust editor

pull/601/head
Pete Matsyburka 4 weeks ago
parent 39cc82ce0c
commit e9c8e4d325

@ -155,3 +155,7 @@ button[disabled] .enabled, button.btn-disabled .enabled {
.font-courier { .font-courier {
font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco; font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco;
} }
markdown-editor [contenteditable] p {
margin-bottom: 18px;
}

@ -1,4 +1,5 @@
import { target, targetable } from '@github/catalyst/lib/targetable' import { target, targetable } from '@github/catalyst/lib/targetable'
import { actionable } from '@github/catalyst/lib/actionable'
function loadTiptap () { function loadTiptap () {
return Promise.all([ return Promise.all([
@ -35,78 +36,63 @@ function loadTiptap () {
} }
class LinkTooltip { class LinkTooltip {
constructor (tooltip, input, saveButton, removeButton, normalizeUrlFn) { constructor (tooltip, input, saveButton, removeButton, editor) {
this.tooltip = tooltip this.tooltip = tooltip
this.input = input this.input = input
this.saveButton = saveButton this.saveButton = saveButton
this.removeButton = removeButton this.removeButton = removeButton
this.normalizeUrl = normalizeUrlFn this.editor = editor
this.targetElement = null
this.clickOutsideTimeout = null
this.closeOnClickOutside = null
this.scrollHandler = null
this.saveHandler = null
this.removeHandler = null
this.keyHandler = null
} }
updatePosition () { isVisible () {
const rect = this.targetElement.getBoundingClientRect() return !this.tooltip.classList.contains('hidden')
this.tooltip.style.left = `${rect.left}px`
this.tooltip.style.top = `${rect.bottom + 5}px`
} }
setupClickOutside () { updatePosition () {
this.closeOnClickOutside = (e) => { const rect = this.editor.view.coordsAtPos(this.pos)
if (!this.tooltip.contains(e.target)) {
this.hide()
}
}
this.clickOutsideTimeout = setTimeout(() => { this.tooltip.style.left = `${rect.left}px`
if (this.closeOnClickOutside) { this.tooltip.style.top = `${rect.bottom + 6}px`
document.addEventListener('click', this.closeOnClickOutside)
}
}, 100)
} }
setupScrollTracking () { normalizeUrl (url) {
this.scrollHandler = () => this.updatePosition() if (!url) return url
window.addEventListener('scroll', this.scrollHandler, true) 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 }) { return `https://${url}`
this.hide() }
show (url, pos) {
this.input.value = url || '' this.input.value = url || ''
this.removeButton.classList.toggle('hidden', !url) this.removeButton.classList.toggle('hidden', !url)
this.targetElement = targetElement this.pos = pos
this.updatePosition()
this.tooltip.classList.remove('hidden') this.tooltip.classList.remove('hidden')
this.input.focus()
this.input.select()
const save = () => { this.updatePosition()
const inputUrl = this.input.value.trim()
this.hide() this.saveHandler = () => {
const inputUrl = this.input.value.trim()
if (inputUrl) { 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 = () => { this.removeHandler = () => {
if (onRemove) onRemove() this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
this.hide() this.hide()
} }
this.keyHandler = (e) => { this.keyHandler = (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
save() this.saveHandler()
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
this.hide() this.hide()
@ -117,26 +103,16 @@ class LinkTooltip {
this.removeButton.addEventListener('click', this.removeHandler, { once: true }) this.removeButton.addEventListener('click', this.removeHandler, { once: true })
this.input.addEventListener('keydown', this.keyHandler) this.input.addEventListener('keydown', this.keyHandler)
this.setupScrollTracking() this.scrollHandler = () => this.updatePosition()
this.setupClickOutside() window.addEventListener('scroll', this.scrollHandler, true)
} }
hide () { hide () {
if (this.clickOutsideTimeout) {
clearTimeout(this.clickOutsideTimeout)
this.clickOutsideTimeout = null
}
if (this.scrollHandler) { if (this.scrollHandler) {
window.removeEventListener('scroll', this.scrollHandler, true) window.removeEventListener('scroll', this.scrollHandler, true)
this.scrollHandler = null this.scrollHandler = null
} }
if (this.closeOnClickOutside) {
document.removeEventListener('click', this.closeOnClickOutside)
this.closeOnClickOutside = null
}
if (this.saveHandler) { if (this.saveHandler) {
this.saveButton.removeEventListener('click', this.saveHandler) this.saveButton.removeEventListener('click', this.saveHandler)
this.saveHandler = null this.saveHandler = null
@ -153,16 +129,14 @@ class LinkTooltip {
} }
this.tooltip?.classList.add('hidden') 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] = [ static [target.static] = [
'textarea', 'textarea',
'editorElement', 'editorElement',
'variableButton',
'variableDropdown',
'boldButton', 'boldButton',
'italicButton', 'italicButton',
'underlineButton', 'underlineButton',
@ -181,14 +155,6 @@ export default targetable(class extends HTMLElement {
this.textarea.style.display = 'none' this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform() 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 { Editor, Extension, Bold, Italic, Paragraph, Text, HardBreak, UndoRedo, Document, Link, Underline, Markdown, Plugin, Decoration, DecorationSet } = await loadTiptap()
const buildDecorations = (doc) => { const buildDecorations = (doc) => {
@ -245,12 +211,20 @@ export default targetable(class extends HTMLElement {
HardBreak, HardBreak,
UndoRedo, UndoRedo,
Link.extend({ Link.extend({
inclusive: false inclusive: true,
addKeyboardShortcuts: () => ({
'Mod-k': () => {
this.toggleLink()
return true
}
})
}).configure({ }).configure({
openOnClick: false, openOnClick: false,
HTMLAttributes: { HTMLAttributes: {
class: 'link', class: 'link',
style: 'color: #2563eb; text-decoration: underline; cursor: pointer;' 'data-turbo': 'false',
style: 'color: #2563eb; text-decoration: underline; cursor: text;'
} }
}), }),
Underline, Underline,
@ -260,58 +234,36 @@ export default targetable(class extends HTMLElement {
contentType: 'markdown', contentType: 'markdown',
editorProps: { editorProps: {
attributes: { attributes: {
class: 'prose prose-sm max-w-none p-3 outline-none focus:outline-none min-h-[120px]' style: 'min-height: 220px',
}, class: 'p-3 outline-none focus:outline-none'
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 }) => { onUpdate: ({ editor }) => {
this.textarea.value = editor.getMarkdown() this.textarea.value = editor.getMarkdown()
this.textarea.dispatchEvent(new Event('input', { bubbles: true })) this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
}, },
onSelectionUpdate: () => { onSelectionUpdate: ({ editor }) => {
this.updateToolbarState() this.updateToolbarState()
this.handleLinkTooltip(editor)
},
onBlur: () => {
setTimeout(() => {
if (!this.linkTooltipElement.contains(document.activeElement)) {
this.linkTooltip.hide()
}
}, 0)
} }
}) })
this.setupToolbar() this.linkTooltip = new LinkTooltip(
this.linkTooltipElement,
if (this.variableButton) { this.linkInput,
this.variableButton.addEventListener('click', () => { this.linkSaveButton,
this.variableDropdown.classList.toggle('hidden') this.linkRemoveButton,
}) this.editor
)
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) => { this.setupToolbar()
if (!this.variableButton.contains(e.target) && !this.variableDropdown.contains(e.target)) {
this.variableDropdown.classList.add('hidden')
}
})
}
} }
adjustShortcutsForPlatform () { adjustShortcutsForPlatform () {
@ -368,36 +320,61 @@ export default targetable(class extends HTMLElement {
this.linkButton?.classList.toggle('bg-base-200', this.editor.isActive('link')) this.linkButton?.classList.toggle('bg-base-200', this.editor.isActive('link'))
} }
normalizeUrl (url) { handleLinkTooltip (editor) {
if (!url) return url const { from } = editor.state.selection
if (/^https?:\/\//i.test(url)) return url const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
if (/^mailto:/i.test(url)) return url
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 () { 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) this.linkTooltip.hide()
const fakeElement = { getBoundingClientRect: () => rect } 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({ if (variable) {
url: previousUrl, const { from, to } = this.editor.state.selection
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) { if (variable.includes('link') && from !== to) {
this.editor.chain().focus().insertContent(`{${variable}}`).run() this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
} else {
this.editor.chain().focus().insertContent(`{${variable}}`).run()
}
}
} }
disconnectedCallback () { disconnectedCallback () {
@ -408,4 +385,4 @@ export default targetable(class extends HTMLElement {
this.editor = null this.editor = null
} }
} }
}) }))

@ -37,15 +37,15 @@
</div> </div>
</div> </div>
<% if local_assigns[:variables]&.any? %> <% 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') } %> <% 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="ml-auto relative"> <div class="dropdown dropdown-end ml-auto">
<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"> <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') %> <%= t('add_variable') %>
<%= svg_icon('chevron_down', class: 'w-3.5 h-3.5') %> <%= svg_icon('chevron_down', class: 'w-3.5 h-3.5') %>
</button> </label>
<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"> <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| %> <% 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"> <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}}") %> <%= variable_labels.fetch(variable, "{#{variable}}") %>
</button> </button>
<% end %> <% end %>
@ -55,7 +55,7 @@
</div> </div>
<div data-target="markdown-editor.editorElement"></div> <div data-target="markdown-editor.editorElement"></div>
</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"> <div data-target="markdown-editor.linkTooltipElement" class="hidden fixed flex bg-white border border-base-300 rounded-xl shadow 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"> <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"> <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') %> <%= svg_icon('check', class: 'w-4 h-4 text-success') %>

@ -1014,7 +1014,7 @@ en: &en
account_name: Account name account_name: Account name
submitter_link: Submitter link submitter_link: Submitter link
template_name: Template name template_name: Template name
submission_submitters: Submission submitters submission_submitters: Submitters list
submission_link: Submission link submission_link: Submission link
documents_link: Documents link documents_link: Documents link
time: time:
@ -2016,7 +2016,7 @@ es: &es
account_name: Nombre de la cuenta account_name: Nombre de la cuenta
submitter_link: Enlace del firmante submitter_link: Enlace del firmante
template_name: Nombre de la plantilla template_name: Nombre de la plantilla
submission_submitters: Firmantes del envío submission_submitters: Lista de firmantes
submission_link: Enlace del envío submission_link: Enlace del envío
documents_link: Enlace de los documentos documents_link: Enlace de los documentos
time: time:
@ -3019,7 +3019,7 @@ it: &it
account_name: Nome dell'account account_name: Nome dell'account
submitter_link: Link del firmatario submitter_link: Link del firmatario
template_name: Nome del modello template_name: Nome del modello
submission_submitters: Firmatari dell'invio submission_submitters: Lista dei firmatari
submission_link: Link dell'invio submission_link: Link dell'invio
documents_link: Link dei documenti documents_link: Link dei documenti
time: time:
@ -4018,7 +4018,7 @@ fr: &fr
account_name: Nom du compte account_name: Nom du compte
submitter_link: Lien du signataire submitter_link: Lien du signataire
template_name: Nom du modèle template_name: Nom du modèle
submission_submitters: Signataires de la soumission submission_submitters: Liste des signataires
submission_link: Lien de la soumission submission_link: Lien de la soumission
documents_link: Lien des documents documents_link: Lien des documents
time: time:
@ -5020,7 +5020,7 @@ pt: &pt
account_name: Nome da conta account_name: Nome da conta
submitter_link: Link do signatário submitter_link: Link do signatário
template_name: Nome do modelo template_name: Nome do modelo
submission_submitters: Signatários da submissão submission_submitters: Lista de signatários
submission_link: Link da submissão submission_link: Link da submissão
documents_link: Link dos documentos documents_link: Link dos documentos
time: time:
@ -6022,7 +6022,7 @@ de: &de
account_name: Kontoname account_name: Kontoname
submitter_link: Link des Unterzeichners submitter_link: Link des Unterzeichners
template_name: Vorlagenname template_name: Vorlagenname
submission_submitters: Unterzeichner der Einreichung submission_submitters: Liste der Unterzeichner
submission_link: Link der Einreichung submission_link: Link der Einreichung
documents_link: Link der Dokumente documents_link: Link der Dokumente
time: time:
@ -7409,7 +7409,7 @@ nl: &nl
account_name: Accountnaam account_name: Accountnaam
submitter_link: Link van de ondertekenaar submitter_link: Link van de ondertekenaar
template_name: Sjabloonnaam template_name: Sjabloonnaam
submission_submitters: Ondertekenaars van de inzending submission_submitters: Lijst van ondertekenaars
submission_link: Link van de inzending submission_link: Link van de inzending
documents_link: Link van de documenten documents_link: Link van de documenten
time: time:

@ -28,7 +28,7 @@ module MarkdownToHtml
link_parts.map.with_index do |part, index| link_parts.map.with_index do |part, index|
if part.match?(%r{\A(?:https?://|www\.)}) && if part.match?(%r{\A(?:https?://|www\.)}) &&
!link_parts[index - 1]&.match?(/\]\(\s*\z/) !(index > 0 && link_parts[index - 1]&.match?(/\]\(\s*\z/))
trail = part.match(/([.,;:!?]+)\z/)[1] if part.match?(/[.,;:!?]+\z/) trail = part.match(/([.,;:!?]+)\z/)[1] if part.match?(/[.,;:!?]+\z/)
clean = trail ? part.chomp(trail) : part clean = trail ? part.chomp(trail) : part
url = clean.start_with?('www.') ? "http://#{clean}" : clean url = clean.start_with?('www.') ? "http://#{clean}" : clean
@ -68,7 +68,6 @@ module MarkdownToHtml
tag = lambda do |t| tag = lambda do |t|
desc = TAGS[t[1] || ''] desc = TAGS[t[1] || '']
return t unless desc return t unless desc
return desc[0] unless desc[1]
is_end = context.last == t is_end = context.last == t
is_end ? context.pop : context.push(t) is_end ? context.pop : context.push(t)
@ -95,15 +94,16 @@ module MarkdownToHtml
elsif m[1] elsif m[1]
chunk = '<a>' chunk = '<a>'
elsif m[5] elsif m[5]
chunk = if m[5] == '***' chunk =
if context.include?('*') && context.include?('**') if m[5] == '***'
tag.call('*') + tag.call('**') if context.include?('*') && context.include?('**')
else tag.call('*') + tag.call('**')
tag.call('**') + tag.call('*') else
end tag.call('**') + tag.call('*')
else end
tag.call(m[5]) else
end tag.call(m[5])
end
end end
out += prev.to_s + chunk out += prev.to_s + chunk

Loading…
Cancel
Save