import { mergeAttributes, Node } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { ReactNodeViewRenderer } from "@tiptap/react"; import { extractVideoId, fetchYouTubeMetadata, isYouTubeUrl } from "./utils"; import YouTubeNodeView from "./YouTubeNodeView"; export interface YouTubeOptions { inline: boolean; HTMLAttributes: Record; } declare module "@tiptap/core" { interface Commands { youTube: { setYouTubeEmbed: (options: { videoId: string; url: string; title: string; thumbnailUrl?: string; showEmbed?: boolean; }) => ReturnType; }; } } export const YouTubeNode = Node.create({ name: "youtube", group: "block", atom: true, addOptions() { return { inline: false, HTMLAttributes: {}, }; }, addAttributes() { return { videoId: { default: null, parseHTML: (element) => element.getAttribute("data-video-id"), renderHTML: (attributes) => { if (!attributes.videoId) return {}; return { "data-video-id": attributes.videoId as string }; }, }, url: { default: null, parseHTML: (element) => element.getAttribute("data-url"), renderHTML: (attributes) => { if (!attributes.url) return {}; return { "data-url": attributes.url as string }; }, }, title: { default: "YouTube Video", parseHTML: (element) => element.getAttribute("data-title"), renderHTML: (attributes) => { return { "data-title": attributes.title as string }; }, }, thumbnailUrl: { default: null, parseHTML: (element) => element.getAttribute("data-thumbnail"), renderHTML: (attributes) => { if (!attributes.thumbnailUrl) return {}; return { "data-thumbnail": attributes.thumbnailUrl as string }; }, }, showEmbed: { default: true, parseHTML: (element) => element.getAttribute("data-show-embed"), renderHTML: (attributes) => { return { "data-show-embed": attributes.showEmbed as string }; }, }, }; }, parseHTML() { return [ { tag: "div[data-youtube]", }, ]; }, renderHTML({ HTMLAttributes }) { return [ "div", mergeAttributes( { "data-youtube": "" }, this.options.HTMLAttributes, HTMLAttributes, ), ]; }, addNodeView() { return ReactNodeViewRenderer(YouTubeNodeView); }, addProseMirrorPlugins() { const nodeType = this.type; return [ new Plugin({ key: new PluginKey("youtubePaste"), props: { handlePaste: (view, event) => { const text = event.clipboardData?.getData("text/plain"); if (!text) return false; // Check if the pasted text is a YouTube URL const youtubeRegex = /(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]+)/; const match = youtubeRegex.exec(text); if (!match || !isYouTubeUrl(text)) return false; const videoId = extractVideoId(text); if (!videoId) return false; const { state, dispatch } = view; const { tr } = state; const node = nodeType.create({ videoId, url: text, title: "Loading...", showEmbed: true, }); tr.replaceSelectionWith(node); dispatch(tr); // Fetch metadata asynchronously and update the node void fetchYouTubeMetadata(text).then((metadata) => { const { state: newState, dispatch: newDispatch } = view; const { tr: newTr } = newState; newState.doc.descendants((n, pos) => { if ( n.type.name === "youtube" && n.attrs.videoId === videoId && n.attrs.title === "Loading..." ) { newTr.setNodeMarkup(pos, undefined, { ...n.attrs, title: metadata?.title ?? "YouTube Video", thumbnailUrl: metadata?.thumbnail_url ?? null, }); return false; } return true; }); if (newTr.docChanged) { newDispatch(newTr); } }); return true; }, }, }), ]; }, // commands for the YouTube embed node addCommands() { return { setYouTubeEmbed: (options: { videoId: string; url: string; title?: string; thumbnailUrl?: string; showEmbed?: boolean; }) => ({ editor }) => { return editor.commands.insertContent({ type: this.name, attrs: { videoId: options.videoId, url: options.url, title: options.title ?? "YouTube Video", thumbnailUrl: options.thumbnailUrl, showEmbed: options.showEmbed ?? true, }, }); }, }; }, });