UNPKG

@limetech/lime-elements

Version:
319 lines (318 loc) • 9.81 kB
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { Fragment } from 'prosemirror-model'; import { EditorMenuTypes, MouseButtons } from '../../menu/types'; import { getLinkAttributes } from './utils'; export const linkPluginKey = new PluginKey('linkPlugin'); const updateLink = (view, updateLinkCallback) => { const { from, to } = view.state.selection; let text = ''; let href = ''; view.state.doc.nodesBetween(from, to, (node, pos) => { if (node.type.name !== 'text') { return; } const fromInNode = Math.max(0, from - pos); const toInNode = Math.min(node.text.length, to - pos); text += node.text.slice(fromInNode, toInNode); // eslint-disable-next-line unicorn/no-array-for-each node.marks.forEach((mark) => { if (mark.type.name === 'link') { href = mark.attrs.href; } }); }); if (updateLinkCallback) { updateLinkCallback(text, href); } }; /** * Finds the start position of the link node ensuring the href matches the original link's href. * @param doc - The ProseMirror document. * @param pos - The position to start searching from. * @param href - The href attribute of the original link mark. * @returns The start position of the link node. */ const findStart = (doc, pos, href) => { while (pos > 0) { const node = doc.nodeAt(pos - 1); if (!(node === null || node === void 0 ? void 0 : node.isText) || !node.marks.some((mark) => mark.type.name === EditorMenuTypes.Link && mark.attrs.href === href)) { break; } pos--; } return pos; }; /** * Finds the end position of the link node ensuring the href matches the original link's href. * @param doc - The ProseMirror document. * @param pos - The position to start searching from. * @param href - The href attribute of the original link mark. * @returns The end position of the link node. */ const findEnd = (doc, pos, href) => { while (pos < doc.content.size) { const node = doc.nodeAt(pos); if (!(node === null || node === void 0 ? void 0 : node.isText) || !node.marks.some((mark) => mark.type.name === EditorMenuTypes.Link && mark.attrs.href === href)) { break; } pos++; } return pos; }; /** * Gets the link data at the specified position. * @param view - The ProseMirror editor view. * @param event - The mouse event. * @returns An object containing the link data or null if no link is found. */ const getLinkDataAtPosition = (view, event) => { const pos = view.posAtCoords({ left: event.clientX, top: event.clientY }); const node = view.state.doc.nodeAt(pos === null || pos === void 0 ? void 0 : pos.pos); if (!node) { return null; } const linkMark = node.marks.find((mark) => mark.type.name === EditorMenuTypes.Link); if (!linkMark) { return null; } const href = linkMark.attrs.href; const from = findStart(view.state.doc, pos.pos, href); const to = findEnd(view.state.doc, pos.pos, href); const text = view.state.doc.textBetween(from, to, ' '); return { href: href, text: text, from: from, to: to }; }; const processModClickEvent = (view, event) => { const { href } = getLinkDataAtPosition(view, event); if (href) { window.open(href, '_blank'); return true; } return false; }; const openLinkMenu = (view, href, text) => { const event = new CustomEvent('open-editor-link-menu', { detail: { href: href, text: text }, bubbles: true, composed: true, }); view.dom.dispatchEvent(event); }; let lastClickTime = 0; const DOUBLE_CLICK_DELAY = 200; let clickTimeout; const processClickEvent = (view, event) => { const now = Date.now(); if (now - lastClickTime < DOUBLE_CLICK_DELAY) { clearTimeout(clickTimeout); lastClickTime = now; // Reset lastClickTime to prevent single-click action return false; } lastClickTime = now; clickTimeout = setTimeout(() => { const linkData = getLinkDataAtPosition(view, event); if (linkData) { const { href, text, from, to } = linkData; const transaction = view.state.tr.setSelection(TextSelection.create(view.state.doc, from, to)); view.dispatch(transaction); openLinkMenu(view, href, text); } }, DOUBLE_CLICK_DELAY); return true; }; /** * Regular expression for matching URLs, mailto links, and phone links */ const URL_REGEX = /(https?:\/\/[^\s<>"']+|mailto:[^\s<>"']+|tel:[^\s<>"']+)/g; /** * Checks if the text contains any URLs, mailto links, or phone links * @param text */ const hasUrls = (text) => { // Reset regex before use URL_REGEX.lastIndex = 0; return URL_REGEX.test(text); }; /** * Creates a text node with the provided content * @param schema * @param content */ const createTextNode = (schema, content) => { return schema.text(content); }; /** * Creates a link node with the provided URL * @param schema * @param url */ const createLinkNode = (schema, url) => { const linkMark = schema.marks.link.create(getLinkAttributes(url, url)); return schema.text(url, [linkMark]); }; /** * Finds all link matches in the provided text * @param text */ const findLinkMatches = (text) => { const matches = []; let match; // Reset regex before use URL_REGEX.lastIndex = 0; while ((match = URL_REGEX.exec(text)) !== null) { matches.push({ url: match[0], start: match.index, end: match.index + match[0].length, }); } return matches; }; /** * Creates text nodes with links for any URLs, mailto links, or phone links found in the text * @param text * @param schema */ const createNodesWithLinks = (text, schema) => { const nodes = []; const matches = findLinkMatches(text); if (matches.length === 0) { // No links found, just return the text as a single node return [createTextNode(schema, text)]; } let lastIndex = 0; // Process each match for (const match of matches) { // Add text before the current link if any if (match.start > lastIndex) { nodes.push(createTextNode(schema, text.slice(lastIndex, match.start))); } // Add the link node nodes.push(createLinkNode(schema, match.url)); lastIndex = match.end; } // Add any remaining text after the last link if (lastIndex < text.length) { nodes.push(createTextNode(schema, text.slice(lastIndex))); } return nodes; }; /** * Pastes nodes at the current selection * @param view - The editor view * @param nodes - Array of nodes to paste */ const pasteAsLink = (view, nodes) => { if (nodes.length === 0) { return; } if (isSingleLinkNode(nodes)) { insertSingleLink(view, nodes[0]); } else { insertNodeFragment(view, nodes); } }; /** * Checks if the nodes array contains just a single link node * @param nodes */ const isSingleLinkNode = (nodes) => { if (nodes.length !== 1) { return false; } const node = nodes[0]; // Must be text with non-empty content if (!node.isText || !node.text || node.text.trim() === '') { return false; } // Must have a link mark (even if there are other marks, we just care about link presence) return node.marks.some((mark) => mark.type.name === 'link'); }; /** * Inserts a single link node, applying it to selected text if present * @param view * @param linkNode */ const insertSingleLink = (view, linkNode) => { const { state, dispatch } = view; const { from, to } = state.selection; const linkMark = linkNode.marks.find((mark) => mark.type.name === 'link'); // Use selected text if there's a selection, otherwise use the URL const selectedText = state.doc.textBetween(from, to, ' ') || linkMark.attrs.href; // Insert the text and add the link mark dispatch(state.tr .insertText(selectedText, from, to) .addMark(from, from + selectedText.length, linkMark)); }; /** * Inserts multiple nodes as a fragment at the current selection * @param view - The editor view * @param nodes - Array of nodes to insert */ const insertNodeFragment = (view, nodes) => { const { state, dispatch } = view; const { from, to } = state.selection; // Create a fragment from the array of nodes const fragment = Fragment.fromArray(nodes); // Replace the current selection with the fragment dispatch(state.tr.replaceWith(from, to, fragment)); }; /** * Handles pasted content, converting URLs to links * @param view * @param event */ const processPasteEvent = (view, event) => { var _a; const text = (_a = event.clipboardData) === null || _a === void 0 ? void 0 : _a.getData('text/plain'); if (!text || !hasUrls(text)) { return false; } const nodes = createNodesWithLinks(text, view.state.schema); pasteAsLink(view, nodes); return true; }; export const createLinkPlugin = (updateLinkCallback) => { return new Plugin({ key: linkPluginKey, props: { handlePaste: (view, event) => { return processPasteEvent(view, event); }, handleDOMEvents: { mousedown: (view, event) => { if ((event.metaKey || event.ctrlKey) && event.button === 0) { return processModClickEvent(view, event); } if (event.button !== MouseButtons.Right) { // We want to ignore right-clicks return processClickEvent(view, event); } return true; }, click: (_view, event) => { if (!(event.target instanceof HTMLElement)) { return; } // Prevent unhandled navigation and bubbling for link clicks const link = event.target.closest('a'); if (link) { event.preventDefault(); event.stopPropagation(); } }, }, }, view: () => ({ update: (view) => { updateLink(view, updateLinkCallback); }, }), }); }; //# sourceMappingURL=link-plugin.js.map