* 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>
197 lines
5.2 KiB
TypeScript
197 lines
5.2 KiB
TypeScript
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,
|
|
},
|
|
});
|
|
},
|
|
};
|
|
},
|
|
});
|