mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-02-06 09:55:47 +00:00
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:
parent
393d882cfe
commit
382f284f31
@ -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])
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user