Files
docmost/packages/editor-ext/src/lib/link.ts
T
Philip Okugbe 89b94e5d19 feat: refactor link menu (#2025)
* link markview - WIP

* WIP

* feat: refactor links

* cleanup
2026-03-15 17:08:59 +00:00

134 lines
4.2 KiB
TypeScript

import { mergeAttributes } from "@tiptap/core";
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' } : {},
},
};
},
parseHTML() {
return [
{
tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])',
getAttrs: (element) => {
if (
element
.getAttribute("href")
?.toLowerCase()
.startsWith("javascript:")
) {
return false;
}
return null;
},
},
];
},
renderHTML({ HTMLAttributes }) {
if (HTMLAttributes.href?.toLowerCase().startsWith("javascript:")) {
return [
"a",
mergeAttributes(
this.options.HTMLAttributes,
{ ...HTMLAttributes, href: "" },
{ class: "link" },
),
0,
];
}
return [
"a",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: "link",
}),
0,
];
},
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 <a> 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;
},
},
}),
];
},
});