|
|
|
@ -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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}))
|
|
|
|
|