feat: add YouTube video embed support for TipTap editor (#294)
* feat: add YouTube video embed support for TipTap editor - Paste YouTube URLs to auto-embed videos with metadata - Edit video title and URL through dropdown menu - Convert embed back to plain URL link - Fetch video titles automatically via YouTube oEmbed API * fix: - reverted NewCardForm CSS properties back to max-h-48. - Added boolean prop to TipTap Edtior for YouTube embed. - Disabled YouTube embed feature for NewCardForm Editor * chore: lint * chore: translations * chore: update translations --------- Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
committed by
GitHub
parent
a5432c60b7
commit
89c8961176
196
apps/web/src/components/YouTubeEmbed/YouTubeNode.tsx
Normal file
196
apps/web/src/components/YouTubeEmbed/YouTubeNode.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
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<string, undefined>;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
youTube: {
|
||||
setYouTubeEmbed: (options: {
|
||||
videoId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
thumbnailUrl?: string;
|
||||
showEmbed?: boolean;
|
||||
}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const YouTubeNode = Node.create<YouTubeOptions>({
|
||||
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,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user