UNPKG

tiptap-link-card

Version:

Link Card Block for TipTap Editor

290 lines (254 loc) 7.87 kB
import { mergeAttributes, Node, PasteRule } from "@tiptap/core"; export type SetLinkCardOptions = { href: string; title: string; description?: string; imageSrc?: string; }; export interface LinkCardOptions { /** * Controls if the link card should open on click. * @default true * @example false */ openOnClick: boolean; /** * Controls if the paste handler for any url should be added. * @default false * @example true */ linkOnPaste: boolean; /** * HTML attributes to add to the link element. * @default {} * @example { class: 'foo' } */ HTMLAttributes: Record<string, any>; /** * Resolves the link card data from a URL. * @param url The URL to resolve. * @description This function is used to resolve the link card data from a URL. * It should return a promise that resolves to an object containing the link card attributes. * @returns A promise that resolves to an object containing the link card attributes. */ dataResolver?: (url: string) => Promise<SetLinkCardOptions>; } type LinkCardCommand<ReturnType> = { /** * Insert a link card * @param options The link card attributes * @example editor.commands.setLinkCard({ href: 'https://example.com', title: 'Example', description: 'An example link', imageSrc: 'https://example.com/image.png' }) */ setLinkCard: (options: SetLinkCardOptions) => ReturnType; /** * Set the link card URL, data will be resolved using the `dataResolver` function if provided. * @param url The URL to set * @example editor.commands.setLinkCardUrl('https://example.com') */ setLinkCardUrl: (url: string) => ReturnType; }; declare module "@tiptap/core" { interface Commands<ReturnType> { linkCard: LinkCardCommand<ReturnType>; } } export const pasteRegex = /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi; const LinkCard = Node.create<LinkCardOptions>({ name: "linkCard", group: "block", atom: true, draggable: true, marks: "", whitespace: "normal", addOptions() { return { openOnClick: true, linkOnPaste: false, dataResolver: null, HTMLAttributes: {}, }; }, addCommands() { return { setLinkCard: (options: SetLinkCardOptions) => ({ commands }) => { return commands.insertContent({ type: this.name, attrs: options, }); }, setLinkCardUrl: (url: string) => ({ commands, editor }) => { if (!this.options.dataResolver) { throw new Error( "dataResolver is not set. Please provide a dataResolver function." ); } const id = Math.random().toString(36).slice(2); (async (id: string) => { const attributes = await this.options.dataResolver!(url); if (!attributes) { throw new Error( "Failed to resolve link card data. Please check the URL or the dataResolver function." ); } const { pos } = editor.$node(this.name, { id }); editor.view.dispatch( editor.state.tr.setNodeMarkup(pos, undefined, attributes) ); })(id); return commands.insertContent({ type: this.name, attrs: { id, isLoading: true, href: url, }, }); }, }; }, addAttributes() { return { id: { default: undefined, }, isLoading: { default: undefined, }, href: { default: null, }, title: { default: null, }, description: { default: null, }, imageSrc: { default: null, }, }; }, parseHTML() { return [ { tag: "link-card", getAttrs: (element: HTMLElement) => { const href = element.getAttribute("href"); if (!href) return false; return { href, title: element.getAttribute("title") || "", description: element.getAttribute("description") || "", imageSrc: element.getAttribute("imageSrc") || "", }; }, }, ]; }, addPasteRules() { if (!this.options.linkOnPaste || !this.options.dataResolver) { return []; } return [ new PasteRule({ find: pasteRegex, handler: ({ match, chain, range, pasteEvent }) => { const id = Math.random().toString(36).slice(2); (async (url: string, id: string) => { const attributes = await this.options.dataResolver!(url); if (!attributes) { throw new Error( "Failed to resolve link card data. Please check the URL or the dataResolver function." ); } const { pos } = this.editor.$node(this.name, { id }); this.editor.view.dispatch( this.editor.state.tr.setNodeMarkup(pos, undefined, attributes) ); })(match.input, id); const node = { type: this.name, attrs: { id, isLoading: true, href: match.input, }, }; return chain() .deleteRange(range) .insertContentAt(range.from, node) .run(); }, }), ]; }, renderHTML({ HTMLAttributes }) { return ["link-card", mergeAttributes(HTMLAttributes)]; }, addNodeView() { return ({ editor, node, getPos }) => { const root = document.createElement( this.options.openOnClick ? "a" : "div" ); const attributes = this.options.HTMLAttributes; Object.keys(attributes).forEach((key) => { root.setAttribute(key, attributes[key]); }); root.classList.add("link-card"); if (this.options.openOnClick) { root.setAttribute("href", node.attrs.href || "#"); root.setAttribute("target", "_blank"); root.setAttribute("rel", "noopener noreferrer"); } else { root.setAttribute("data-href", node.attrs.href || "#"); } if (node.attrs.imageSrc) { const figure = document.createElement("figure"); figure.classList.add("link-card-figure"); const img = document.createElement("img"); img.classList.add("link-card-image"); img.src = node.attrs.imageSrc; figure.appendChild(img); root.appendChild(figure); } const meta = document.createElement("div"); meta.classList.add("link-card-meta"); if (node.attrs.title) { const title = document.createElement("h3"); title.classList.add("link-card-title"); title.textContent = node.attrs.title; meta.appendChild(title); } if (node.attrs.description) { const description = document.createElement("p"); description.classList.add("link-card-description"); description.textContent = node.attrs.description; meta.appendChild(description); } if (node.attrs.href) { const url = new URL(node.attrs.href); const domain = document.createElement("span"); domain.classList.add("link-card-domain"); domain.textContent = url.hostname; meta.appendChild(domain); } root.appendChild(meta); if (node.attrs.isLoading) { const loadingIndicator = document.createElement("span"); loadingIndicator.classList.add("link-card-loading"); loadingIndicator.textContent = "Loading..."; root.appendChild(loadingIndicator); } return { dom: root, }; }; }, }); export { LinkCard }; export default LinkCard;