import Token from "markdown-it/lib/token"; import { toggleMark } from "prosemirror-commands"; import { InputRule } from "prosemirror-inputrules"; import { MarkdownSerializerState } from "prosemirror-markdown"; import { MarkSpec, MarkType, Node, Mark as ProsemirrorMark, } from "prosemirror-model"; import { Command, EditorState, Plugin, TextSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { toast } from "sonner"; import { sanitizeUrl } from "../../utils/urls"; import { getMarkRange } from "../queries/getMarkRange"; import { isMarkActive } from "../queries/isMarkActive"; import { EventType } from "../types"; import Mark from "./Mark"; const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/; function isPlainURL( link: ProsemirrorMark, parent: Node, index: number, side: -1 | 1 ) { if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) { return false; } const content = parent.child(index + (side < 0 ? -1 : 0)); if ( !content.isText || content.text !== link.attrs.href || content.marks[content.marks.length - 1] !== link ) { return false; } if (index === (side < 0 ? 1 : parent.childCount - 1)) { return true; } const next = parent.child(index + (side < 0 ? -2 : 1)); return !link.isInSet(next.marks); } export default class Link extends Mark { get name() { return "link"; } get schema(): MarkSpec { return { attrs: { href: { default: "", }, title: { default: null, }, }, inclusive: false, parseDOM: [ { tag: "a[href]", getAttrs: (dom: HTMLElement) => ({ href: dom.getAttribute("href"), title: dom.getAttribute("title"), }), }, ], toDOM: (node) => [ "a", { title: node.attrs.title, href: sanitizeUrl(node.attrs.href), class: "use-hover-preview", rel: "noopener noreferrer nofollow", }, 0, ], }; } inputRules({ type }: { type: MarkType }) { return [ new InputRule(LINK_INPUT_REGEX, (state, match, start, end) => { const [okay, alt, href] = match; const { tr } = state; if (okay) { tr.replaceWith(start, end, this.editor.schema.text(alt)).addMark( start, start + alt.length, type.create({ href }) ); } return tr; }), ]; } keys({ type }: { type: MarkType }): Record { return { "Mod-k": (state, dispatch) => { if (state.selection.empty) { this.editor.events.emit(EventType.LinkToolbarOpen); return true; } return toggleMark(type, { href: "" })(state, dispatch); }, "Mod-Enter": (state) => { if (isMarkActive(type)(state)) { const range = getMarkRange( state.selection.$from, state.schema.marks.link ); if (range && range.mark && this.options.onClickLink) { try { const event = new KeyboardEvent("keydown", { metaKey: false }); this.options.onClickLink( sanitizeUrl(range.mark.attrs.href), event ); } catch (err) { toast.error(this.options.dictionary.openLinkError); } return true; } } return false; }, }; } get plugins() { const handleClick = (view: EditorView, pos: number) => { const { doc, tr } = view.state; const range = getMarkRange( doc.resolve(pos), this.editor.schema.marks.link ); if (!range || range.from === pos || range.to === pos) { return false; } try { const $start = doc.resolve(range.from); const $end = doc.resolve(range.to); tr.setSelection(new TextSelection($start, $end)); view.dispatch(tr); return true; } catch (err) { // Failed to set selection } return false; }; const plugin: Plugin = new Plugin({ props: { decorations: (state: EditorState) => plugin.getState(state), handleDOMEvents: { contextmenu: (view: EditorView, event: MouseEvent) => { const result = view.posAtCoords({ left: event.clientX, top: event.clientY, }); if (result) { return handleClick(view, result.pos); } return false; }, mousedown: (view: EditorView, event: MouseEvent) => { const target = (event.target as HTMLElement)?.closest("a"); if (!(target instanceof HTMLAnchorElement) || event.button !== 0) { return false; } if (target.matches(".component-attachment *")) { return false; } if (target.role === "button") { return false; } // clicking a link while editing should show the link toolbar, // clicking in read-only will navigate if (!view.editable || (view.editable && !view.hasFocus())) { const href = target.href || (target.parentNode instanceof HTMLAnchorElement ? target.parentNode.href : ""); try { if (this.options.onClickLink) { event.stopPropagation(); event.preventDefault(); this.options.onClickLink(sanitizeUrl(href), event); } } catch (err) { toast.error(this.options.dictionary.openLinkError); } return true; } const result = view.posAtCoords({ left: event.clientX, top: event.clientY, }); if (result && handleClick(view, result.pos)) { event.preventDefault(); return true; } return false; }, click: (view: EditorView, event: MouseEvent) => { if ( !(event.target instanceof HTMLAnchorElement) || event.button !== 0 ) { return false; } if (event.target.matches(".component-attachment *")) { return false; } // Prevent all default click behavior of links, see mousedown above // for custom link handling. if (this.options.onClickLink) { event.stopPropagation(); event.preventDefault(); } return false; }, }, }, }); return [plugin]; } toMarkdown() { return { open: ( _state: MarkdownSerializerState, mark: ProsemirrorMark, parent: Node, index: number ) => (isPlainURL(mark, parent, index, 1) ? "<" : "["), close: ( state: MarkdownSerializerState, mark: ProsemirrorMark, parent: Node, index: number ) => isPlainURL(mark, parent, index, -1) ? ">" : "](" + state.esc(mark.attrs.href) + (mark.attrs.title ? " " + quote(mark.attrs.title) : "") + ")", }; } parseMarkdown() { return { mark: "link", getAttrs: (token: Token) => ({ href: token.attrGet("href"), title: token.attrGet("title") || null, }), }; } } function quote(str: string) { const wrap = str.indexOf('"') === -1 ? '""' : str.indexOf("'") === -1 ? "''" : "()"; return wrap[0] + str + wrap[1]; }