UNPKG

@limetech/lime-elements

Version:
355 lines (354 loc) • 12 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 linkData = getLinkDataAtPosition(view, event); if (!linkData.href) { return false; } event.preventDefault(); const { href } = linkData; if (href) { window.open(href, '_blank', 'noopener,noreferrer'); 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); }; const processDoubleClickEvent = (view, event) => { const linkData = getLinkDataAtPosition(view, event); if (!linkData) { return false; } 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); return true; }; /** * Regular expression for matching URLs, mailto links, phone links, and bare www-links */ const URL_REGEX = /(https?:\/\/[^\s<>"']+|mailto:[^\s<>"']+|tel:[^\s<>"']+|www\.[^\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 normalizeUrlForLinkMark = (input) => { let output = input.trim(); while (output.endsWith('\\')) { output = output.slice(0, -1); } if (output.toLowerCase().startsWith('www.')) { output = `https://${output}`; } return output; }; const normalizedUrl = normalizeUrlForLinkMark(url); const linkMark = schema.marks.link.create(getLinkAttributes(normalizedUrl, normalizedUrl)); return schema.text(normalizedUrl, [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 nodes for the pasted text while preserving soft line breaks. * - Each newline becomes a `hard_break`. * - Empty lines are preserved (consecutive newlines => multiple `hard_break`s). * - URLs inside each line are converted to link-marked text. * @param text - Raw pasted text * @param schema - ProseMirror schema */ const createNodesWithLinksAndBreaks = (text, schema) => { // Split preserves empty lines between consecutive newlines const lines = text.split(/\r\n|\r|\n/); const nodes = []; for (const [index, line] of lines.entries()) { if (line.length > 0) { nodes.push(...createNodesWithLinks(line, schema)); } if (index < lines.length - 1) { const hb = schema.nodes.hard_break; if (hb) { nodes.push(hb.create()); } else { // Fallback: if schema lacks hard_break, defer to default paste behavior // (Do NOT throw; keep behavior stable across versions) console.warn('hard_break node not found in schema'); } } } return nodes; }; /** * 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 = createNodesWithLinksAndBreaks(text, view.state.schema); event.preventDefault(); 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); } }, dblclick: (view, event) => { if (event.button !== MouseButtons.Right) { // We want to ignore right-clicks return processDoubleClickEvent(view, event); } }, 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); }, }), }); };