diff --git a/lib/src/Components/Input/RichTextEditor.tsx b/lib/src/Components/Input/RichTextEditor.tsx index 01045291..c2dba635 100644 --- a/lib/src/Components/Input/RichTextEditor.tsx +++ b/lib/src/Components/Input/RichTextEditor.tsx @@ -7,6 +7,9 @@ import { StarterKit } from '@tiptap/starter-kit' import { useEffect } from 'react' import { Markdown } from 'tiptap-markdown' +import { VideoEmbed } from '#components/TipTap/extensions/VideoEmbed' +import { preprocessVideoLinks } from '#components/TipTap/utils/preprocessMarkdown' + import { InputLabel } from './InputLabel' import { TextEditorMenu } from './TextEditorMenu' @@ -73,8 +76,9 @@ export function RichTextEditor({ placeholder, emptyEditorClass: 'is-editor-empty', }), + VideoEmbed, ], - content: defaultValue, + content: preprocessVideoLinks(defaultValue), onUpdate: handleChange, editorProps: { attributes: { @@ -85,7 +89,7 @@ export function RichTextEditor({ useEffect(() => { if (editor.storage.markdown.getMarkdown() === '' || !editor.storage.markdown.getMarkdown()) { - editor.commands.setContent(defaultValue) + editor.commands.setContent(preprocessVideoLinks(defaultValue)) } }, [defaultValue, editor]) diff --git a/lib/src/Components/TipTap/extensions/VideoEmbed.tsx b/lib/src/Components/TipTap/extensions/VideoEmbed.tsx index f1a22156..64f89183 100644 --- a/lib/src/Components/TipTap/extensions/VideoEmbed.tsx +++ b/lib/src/Components/TipTap/extensions/VideoEmbed.tsx @@ -1,4 +1,35 @@ import { mergeAttributes, Node } from '@tiptap/core' +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { Plugin, PluginKey } from '@tiptap/pm/state' + +import type { NodeViewProps } from '@tiptap/react' + +// Regex patterns for video URL detection +const YOUTUBE_REGEX = /(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]+)/ +const YOUTUBE_SHORT_REGEX = /(?:https?:\/\/)?youtu\.be\/([a-zA-Z0-9_-]+)/ +const RUMBLE_REGEX = /(?:https?:\/\/)?rumble\.com\/embed\/([a-zA-Z0-9_-]+)/ + +/** + * Extracts video provider and ID from a URL + */ +function parseVideoUrl(url: string): { provider: 'youtube' | 'rumble'; videoId: string } | null { + let match = url.match(YOUTUBE_REGEX) + if (match) { + return { provider: 'youtube', videoId: match[1] } + } + + match = url.match(YOUTUBE_SHORT_REGEX) + if (match) { + return { provider: 'youtube', videoId: match[1] } + } + + match = url.match(RUMBLE_REGEX) + if (match) { + return { provider: 'rumble', videoId: match[1] } + } + + return null +} export interface VideoEmbedOptions { HTMLAttributes: Record @@ -23,6 +54,25 @@ export const VideoEmbed = Node.create({ } }, + addStorage() { + return { + markdown: { + serialize(state: { write: (text: string) => void }, node: { attrs: { provider: string; videoId: string } }) { + const { provider, videoId } = node.attrs + const url = + provider === 'youtube' + ? `https://www.youtube.com/watch?v=${videoId}` + : `https://rumble.com/embed/${videoId}` + // Write as markdown autolink + state.write(`<${url}>`) + }, + parse: { + // Parsing is handled by preprocessVideoLinks + }, + }, + } + }, + addAttributes() { return { provider: { @@ -72,6 +122,10 @@ export const VideoEmbed = Node.create({ ] }, + addNodeView() { + return ReactNodeViewRenderer(VideoEmbedComponent) + }, + addCommands() { return { setVideoEmbed: @@ -84,4 +138,58 @@ export const VideoEmbed = Node.create({ }, } }, + + addProseMirrorPlugins() { + const nodeType = this.type + + return [ + new Plugin({ + key: new PluginKey('videoEmbedPaste'), + props: { + handlePaste(view, event) { + const text = event.clipboardData?.getData('text/plain') + if (!text) return false + + const videoInfo = parseVideoUrl(text.trim()) + if (!videoInfo) return false + + // Insert video embed node + const { state, dispatch } = view + const node = nodeType.create(videoInfo) + const tr = state.tr.replaceSelectionWith(node) + dispatch(tr) + + return true + }, + }, + }), + ] + }, }) + +/** + * React component for rendering video embeds in the editor. + * Shows an iframe preview of YouTube/Rumble videos. + */ +function VideoEmbedComponent({ node }: NodeViewProps) { + const { provider, videoId } = node.attrs as { provider: string; videoId: string } + + const src = + provider === 'youtube' + ? `https://www.youtube-nocookie.com/embed/${videoId}` + : `https://rumble.com/embed/${videoId}` + + return ( + +
+