Files
nocodb/packages/nc-gui/helpers/tiptap-markdown/extensions/marks/link.ts
2025-06-26 12:47:59 +00:00

221 lines
6.7 KiB
TypeScript

import TiptapLink, { type LinkOptions } from '@tiptap/extension-link'
import { mergeAttributes } from '@tiptap/core'
import { Plugin, TextSelection } from '@tiptap/pm/state'
import type { AddMarkStep, Step } from '@tiptap/pm/transform'
import { defaultMarkdownSerializer } from '@tiptap/pm/markdown'
export const Link = TiptapLink.extend<LinkOptions>({
addOptions() {
return {
...this.parent?.(),
openOnClick: true,
linkOnPaste: true,
autolink: true,
protocols: [],
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
},
validate: (_url) => true,
internal: false,
}
},
addAttributes() {
return {
href: {
default: null,
},
target: {
default: this.options.HTMLAttributes.target,
},
class: {
default: this.options.HTMLAttributes.class,
},
}
},
renderHTML({ HTMLAttributes }) {
const attr = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
if (isValidURL(attr.href)) {
return [
'a',
{
...attr,
onclick: '(function(event) { window.tiptapLinkHandler?.(event);})(event)', // Global handler
},
0,
]
}
// We use this as a workaround to show a tooltip on the content
// We use the href to store the tooltip content
if (!attr.href.includes('~~~###~~~')) {
return ['a', attr, 0]
}
// The class is used to identify the text that needs to show the tooltip
// The data-tooltip is the content of the tooltip
attr.class = 'nc-rich-link-tooltip'
attr['data-tooltip'] = attr.href?.split('~~~###~~~')[1]?.replace(/_/g, ' ')
return ['span', attr]
},
addKeyboardShortcuts() {
return {
'Mod-j': () => {
const selection = this.editor.view.state.selection
this.editor
.chain()
.toggleLink({
href: '',
})
.setTextSelection(selection.to)
.run()
setTimeout(() => {
const linkInput = document.querySelector('.nc-text-area-rich-link-option-input')
if (linkInput) {
;(linkInput as any).focus()
}
}, 100)
},
'Space': () => {
const { state } = this.editor.view
const { selection } = state
const { $to } = selection
const nodeBefore = $to.nodeBefore
const nodeAfter = $to.nodeAfter
const linkMarkType = state.schema.marks.link
if (!linkMarkType || !nodeBefore) return false
// Check if the cursor is inside a link
const linkMark = linkMarkType.isInSet(nodeBefore.marks)
if (!linkMark) return false
// Ensure space is typed at the very end of the link
const isAtEndOfLink = !nodeAfter || !linkMarkType.isInSet(nodeAfter.marks)
if (!isAtEndOfLink) return false
// ✅ Insert space and then remove ALL marks
const tr = state.tr.insertText(' ', $to.pos).setSelection(state.selection)
this.editor.view.dispatch(tr)
this.editor.commands.unsetAllMarks() // This clears bold, italic, underline, link, etc.
return true
},
} as any
},
addProseMirrorPlugins() {
return [
// To have proseMirror plugins from the parent extension
...(this.parent?.() ?? []),
new Plugin({
//
// Put cursor at the end of the link when we add a link
//
appendTransaction: (transactions, _, newState) => {
try {
if (transactions.length !== 1) return null
const steps = transactions[0].steps
if (steps.length !== 1) return null
const step: Step = steps[0] as Step
const stepJson = step.toJSON()
// Ignore we are not adding a mark(i.e link, bold, etc)
if (stepJson.stepType !== 'addMark') return null
const addMarkStep: AddMarkStep = step as AddMarkStep
if (!addMarkStep) return null
if (addMarkStep.from === addMarkStep.to) return null
if (addMarkStep.mark.type.name !== 'link') return null
const { tr } = newState
return tr.setSelection(new TextSelection(tr.doc.resolve(addMarkStep.to)))
} catch (e) {
console.error(e)
return null
}
},
}),
// ✅ Remove link when typing after it and before it
new Plugin({
appendTransaction: (transactions, oldState, newState) => {
try {
// ✅ Skip if it's a paste transaction
if (transactions.some((tr) => tr.getMeta('paste') || tr.getMeta('uiEvent') === 'paste')) {
return null
}
if (transactions.length !== 1) return null
const steps = transactions[0].steps
if (steps.length !== 1) return null
const step: Step = steps[0] as Step
const stepJson = step.toJSON()
// If this is not inserting a new character, ignore
if (stepJson.stepType !== 'replace' || !stepJson.slice) return null
const { selection } = oldState
const { $from, $to } = selection
const nodeBefore = $to.nodeBefore
const nodeAfter = $to.nodeAfter
const linkMarkType = newState.schema.marks.link
if (!linkMarkType) return null
const tr = newState.tr
// ✅ Case 1: Typing at the END of a link
if (nodeBefore) {
const linkMark = linkMarkType.isInSet(nodeBefore.marks)
if (linkMark) {
const isAtEndOfLink = !nodeAfter || !linkMarkType.isInSet(nodeAfter.marks)
if (isAtEndOfLink) {
// 🔹 Remove all formatting after typing at end of marked text
nodeBefore?.marks?.forEach((mark) => {
tr.removeMark($to.pos, $to.pos + 1, mark.type)
})
return tr
}
}
}
// ✅ Case 2: Typing at the START of a link
if ($from.pos === 0 || (!$from.nodeBefore && nodeAfter && linkMarkType.isInSet(nodeAfter.marks))) {
// 🔹 Remove all formatting after typing at end of marked text
nodeAfter?.marks?.forEach((mark) => {
tr.removeMark($from.pos, $from.pos + 1, mark.type)
})
return tr
}
return null
} catch (e) {
console.error(e)
return null
}
},
}),
]
},
addStorage() {
return {
markdown: {
serialize: defaultMarkdownSerializer.marks.link,
parse: {
// handled by markdown-it
},
},
}
},
}).configure({
openOnClick: false,
})