import { Plugin, PluginKey, EditorState } from 'prosemirror-state'; import { Mark } from 'prosemirror-model'; import { isSafeUrl } from '../sanitize'; const URL_PATTERN = /^https?:\/\/\S+$/i; export interface LinkPluginOptions { /** Called with the href when a link is Mod/Ctrl-clicked. */ onOpenLink?: (href: string) => void; } /** Where retyped link text continues: the caret position and the link it keeps. */ interface RetypeState { pos: number; mark: Mark; } const retypeKey = new PluginKey('nile-wysiwyg-link-retype'); /** The link mark covering every text node in [from, to), or null. */ function linkCovering(state: EditorState, from: number, to: number): Mark | null { const type = state.schema.marks.link; let mark: Mark | null = null; let covered = true; state.doc.nodesBetween(from, to, node => { if (!covered || !node.isText) return; const found = type.isInSet(node.marks); if (!found || (mark && !found.eq(mark))) covered = false; else mark = found; }); return covered ? mark : null; } /** * Link behaviors that belong to the document, not the chrome: * - Mod/Ctrl-click opens the link. * - Pasting a bare URL over a text selection turns the selection into a link. * - Typing over a selection that is entirely one link keeps the link, for the * replaced text and for the characters typed straight after it (the link mark * is non-inclusive, so without this the new label drops out of the link — an * email button renamed by retyping lost its href). */ export function linkPlugin(options: LinkPluginOptions = {}): Plugin { return new Plugin({ key: retypeKey, state: { init: () => null, apply(tr, prev) { const meta = tr.getMeta(retypeKey) as RetypeState | null | undefined; if (meta !== undefined) return meta; if (!prev) return null; // Any caret move, or an edit elsewhere, ends the retype run. if (!tr.docChanged) return tr.selectionSet ? null : prev; const pos = tr.mapping.map(prev.pos); return tr.selection.empty && tr.selection.from === pos ? { ...prev, pos } : null; }, }, props: { handleTextInput(view, from, to, text) { const { state } = view; let mark: Mark | null = null; if (from < to) mark = linkCovering(state, from, to); else { const run = retypeKey.getState(state); if (run && run.pos === from) mark = run.mark; } if (!mark) return false; const end = from + text.length; const tr = state.tr.insertText(text, from, to).addMark(from, end, mark); tr.setMeta(retypeKey, { pos: end, mark }); view.dispatch(tr); return true; }, handleClick(view, _pos, event) { if (!(event.metaKey || event.ctrlKey)) return false; const target = (event.target as HTMLElement | null)?.closest?.('a[href]'); if (!target) return false; const href = target.getAttribute('href')!; if (!isSafeUrl(href)) return false; if (options.onOpenLink) options.onOpenLink(href); else window.open(href, '_blank', 'noopener,noreferrer'); return true; }, handlePaste(view, event) { const { state } = view; const { empty, from, to } = state.selection; if (empty) return false; const text = event.clipboardData?.getData('text/plain')?.trim(); if (!text || !URL_PATTERN.test(text) || !isSafeUrl(text)) return false; const linkMark = state.schema.marks.link.create({ href: text }); view.dispatch(state.tr.addMark(from, to, linkMark)); return true; }, }, }); }