feat(VideoEmbed): add paste handler, editor preview, and markdown serialization

- Add ReactNodeViewRenderer for video preview in RichTextEditor
- Add ProseMirror plugin for paste detection of video URLs
- Add markdown serialization to output autolink format <url>
- Integrate VideoEmbed extension in RichTextEditor
- Preprocess video links when loading content

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Anton Tranelis 2026-01-14 11:41:14 +01:00
parent 393d882cfe
commit 382f284f31
2 changed files with 114 additions and 2 deletions

View File

@ -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])

View File

@ -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<string, unknown>
@ -23,6 +54,25 @@ export const VideoEmbed = Node.create<VideoEmbedOptions>({
}
},
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<VideoEmbedOptions>({
]
},
addNodeView() {
return ReactNodeViewRenderer(VideoEmbedComponent)
},
addCommands() {
return {
setVideoEmbed:
@ -84,4 +138,58 @@ export const VideoEmbed = Node.create<VideoEmbedOptions>({
},
}
},
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 (
<NodeViewWrapper>
<div className='video-embed-wrapper' contentEditable={false}>
<iframe
src={src}
allowFullScreen
allow='fullscreen; picture-in-picture'
className='video-embed'
frameBorder='0'
/>
</div>
</NodeViewWrapper>
)
}