import TiptapLink from '@tiptap/extension-link'; import { Plugin, PluginKey } from '@tiptap/pm/state'; import { EditorView } from '@tiptap/pm/view'; export const LinkExtension = TiptapLink.extend({ inclusive: false, addAttributes() { return { ...this.parent?.(), internal: { default: false, parseHTML: (element: HTMLElement) => element.getAttribute('data-internal') === 'true', renderHTML: (attributes) => attributes.internal ? { 'data-internal': 'true' } : {}, }, }; }, addProseMirrorPlugins() { const { editor } = this; return [ ...(this.parent?.() || []), new Plugin({ props: { handleKeyDown: (view: EditorView, event: KeyboardEvent) => { const { selection } = editor.state; if (event.key === 'Escape' && selection.empty !== true) { editor.commands.focus(selection.to, { scrollIntoView: false }); } return false; }, }, }), // Fix for Firefox: when the cursor is at a boundary of a link, // Firefox's contenteditable inserts new text *inside* the element. // ProseMirror then rejects the mutation because inclusive is false, // causing keystrokes to be silently swallowed. Firefox also does not // fire handleTextInput in this state, so we intercept at handleKeyDown. // This handles both: // - right boundary: cursor just after a link (typing appends to link) // - left boundary: cursor just before a link, e.g. at the start of a // line (#1748), where Firefox places new text inside the link node new Plugin({ key: new PluginKey('linkBoundaryInput'), props: { handleKeyDown: (view: EditorView, event: KeyboardEvent) => { // Only handle single printable characters if (event.key.length !== 1) return false; // Don't handle modified keys (shortcuts) or composing (IME) if ( event.ctrlKey || event.metaKey || event.altKey || event.isComposing ) return false; const { state } = view; const linkType = state.schema.marks.link; if (!linkType) return false; // Don't interfere if the user has explicitly set storedMarks if (state.storedMarks !== null) return false; const { from, to } = state.selection; const $from = state.doc.resolve(from); const nodeBefore = $from.nodeBefore; const nodeAfter = $from.nodeAfter; const linkBefore = nodeBefore && linkType.isInSet(nodeBefore.marks); const linkAfter = nodeAfter && linkType.isInSet(nodeAfter.marks); // If both sides have link marks we're in the middle — don't interfere if (linkBefore && linkAfter) return false; // Not at any link boundary — nothing to do if (!linkBefore && !linkAfter) return false; // We're at a link boundary (left or right). // Prevent native input and insert text without the link mark. event.preventDefault(); const tr = state.tr.insertText(event.key, from, to); tr.removeMark(from, from + event.key.length, linkType); view.dispatch(tr.scrollIntoView()); return true; }, }, }), ]; }, });