mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-02-06 09:55:47 +00:00
fix(tiptap): add null checks, extract video patterns to shared module
- Add null checks for editor in RichTextEditor (handleChange, useEffect) - Extract video URL patterns to shared videoPatterns.ts module - Refactor VideoEmbed.tsx and preprocessMarkdown.ts to use shared patterns - Add helper functions: getVideoEmbedUrl, getVideoCanonicalUrl, parseVideoUrl - Remove unused markdownToTiptapJson function Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
04c8001114
commit
9f74815ee3
@ -54,6 +54,8 @@ export function RichTextEditor({
|
||||
)
|
||||
|
||||
const handleChange = () => {
|
||||
if (!editor) return
|
||||
|
||||
let newValue: string | undefined = editor.getMarkdown()
|
||||
|
||||
const regex = /!\[.*?\]\(.*?\)/g
|
||||
@ -104,6 +106,8 @@ export function RichTextEditor({
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
|
||||
if (editor.getMarkdown() === '' || !editor.getMarkdown()) {
|
||||
editor.commands.setContent(defaultValue, { contentType: 'markdown' })
|
||||
}
|
||||
|
||||
@ -2,37 +2,15 @@ import { mergeAttributes, Node } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
|
||||
|
||||
import {
|
||||
VIDEO_AUTOLINK_PATTERNS,
|
||||
getVideoCanonicalUrl,
|
||||
getVideoEmbedUrl,
|
||||
parseVideoUrl,
|
||||
} from '#components/TipTap/utils/videoPatterns'
|
||||
|
||||
import type { NodeViewProps } from '@tiptap/react'
|
||||
|
||||
// Regex patterns for video URL detection
|
||||
// Using possessive-like patterns with specific character classes to avoid ReDoS
|
||||
// YouTube IDs are typically 11 chars but we allow 10-12 for flexibility
|
||||
const YOUTUBE_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{10,12})(?:&|$)/
|
||||
const YOUTUBE_SHORT_REGEX = /^https?:\/\/youtu\.be\/([a-zA-Z0-9_-]{10,12})(?:\?|$)/
|
||||
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 = YOUTUBE_REGEX.exec(url)
|
||||
if (match) {
|
||||
return { provider: 'youtube', videoId: match[1] }
|
||||
}
|
||||
|
||||
match = YOUTUBE_SHORT_REGEX.exec(url)
|
||||
if (match) {
|
||||
return { provider: 'youtube', videoId: match[1] }
|
||||
}
|
||||
|
||||
match = RUMBLE_REGEX.exec(url)
|
||||
if (match) {
|
||||
return { provider: 'rumble', videoId: match[1] }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export interface VideoEmbedOptions {
|
||||
HTMLAttributes: Record<string, unknown>
|
||||
}
|
||||
@ -72,9 +50,7 @@ export const VideoEmbed = Node.create<VideoEmbedOptions>({
|
||||
},
|
||||
tokenize: (src: string) => {
|
||||
// Match YouTube autolinks: <https://www.youtube.com/watch?v=VIDEO_ID>
|
||||
let match = /^<https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{10,12})[^>]*>/.exec(
|
||||
src,
|
||||
)
|
||||
let match = VIDEO_AUTOLINK_PATTERNS.youtube.exec(src)
|
||||
if (match) {
|
||||
return {
|
||||
type: 'videoEmbed',
|
||||
@ -85,7 +61,7 @@ export const VideoEmbed = Node.create<VideoEmbedOptions>({
|
||||
}
|
||||
|
||||
// Match YouTube short autolinks: <https://youtu.be/VIDEO_ID>
|
||||
match = /^<https?:\/\/youtu\.be\/([a-zA-Z0-9_-]{10,12})[^>]*>/.exec(src)
|
||||
match = VIDEO_AUTOLINK_PATTERNS.youtubeShort.exec(src)
|
||||
if (match) {
|
||||
return {
|
||||
type: 'videoEmbed',
|
||||
@ -96,7 +72,7 @@ export const VideoEmbed = Node.create<VideoEmbedOptions>({
|
||||
}
|
||||
|
||||
// Match Rumble autolinks: <https://rumble.com/embed/VIDEO_ID>
|
||||
match = /^<https?:\/\/rumble\.com\/embed\/([a-zA-Z0-9]+)[^>]*>/.exec(src)
|
||||
match = VIDEO_AUTOLINK_PATTERNS.rumble.exec(src)
|
||||
if (match) {
|
||||
return {
|
||||
type: 'videoEmbed',
|
||||
@ -124,10 +100,7 @@ export const VideoEmbed = Node.create<VideoEmbedOptions>({
|
||||
// Serialize Tiptap node to Markdown
|
||||
renderMarkdown(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}`
|
||||
const url = getVideoCanonicalUrl(provider as 'youtube' | 'rumble', videoId)
|
||||
return `<${url}>`
|
||||
},
|
||||
|
||||
@ -156,11 +129,7 @@ export const VideoEmbed = Node.create<VideoEmbedOptions>({
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
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}`
|
||||
const src = getVideoEmbedUrl(provider as 'youtube' | 'rumble', videoId)
|
||||
|
||||
return [
|
||||
'div',
|
||||
@ -231,11 +200,7 @@ export const VideoEmbed = Node.create<VideoEmbedOptions>({
|
||||
*/
|
||||
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}`
|
||||
const src = getVideoEmbedUrl(provider as 'youtube' | 'rumble', videoId)
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
|
||||
import { fixUrls, mailRegex } from '#utils/ReplaceURLs'
|
||||
|
||||
import type { JSONContent, Extensions } from '@tiptap/core'
|
||||
import { VIDEO_PREPROCESS_PATTERNS, createVideoEmbedTag } from './videoPatterns'
|
||||
|
||||
/**
|
||||
* Converts naked URLs to markdown links, but skips URLs that are already
|
||||
@ -66,24 +64,6 @@ function convertNakedUrls(text: string): string {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts pre-processed markdown/HTML to TipTap JSON format.
|
||||
* Creates a temporary editor instance to parse the content.
|
||||
*/
|
||||
export function markdownToTiptapJson(content: string, extensions: Extensions): JSONContent {
|
||||
// Create a temporary editor to parse HTML/markdown
|
||||
const editor = new Editor({
|
||||
extensions,
|
||||
content,
|
||||
// We immediately destroy this, so no need for DOM attachment
|
||||
})
|
||||
|
||||
const json = editor.getJSON()
|
||||
editor.destroy()
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-processes markdown text before passing to TipTap.
|
||||
* - Converts naked URLs to markdown links
|
||||
@ -127,39 +107,36 @@ export function preprocessVideoLinks(text: string): string {
|
||||
let result = text
|
||||
|
||||
// YouTube autolinks: <https://www.youtube.com/watch?v=VIDEO_ID>
|
||||
result = result.replace(
|
||||
/<https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([^>&]+)[^>]*>/g,
|
||||
'<video-embed provider="youtube" video-id="$1"></video-embed>',
|
||||
result = result.replace(VIDEO_PREPROCESS_PATTERNS.youtubeAutolink, (_, videoId: string) =>
|
||||
createVideoEmbedTag('youtube', videoId),
|
||||
)
|
||||
|
||||
// YouTube short autolinks: <https://youtu.be/VIDEO_ID>
|
||||
result = result.replace(
|
||||
/<https?:\/\/youtu\.be\/([^>?]+)[^>]*>/g,
|
||||
'<video-embed provider="youtube" video-id="$1"></video-embed>',
|
||||
result = result.replace(VIDEO_PREPROCESS_PATTERNS.youtubeShortAutolink, (_, videoId: string) =>
|
||||
createVideoEmbedTag('youtube', videoId),
|
||||
)
|
||||
|
||||
// YouTube: [Text](https://www.youtube.com/watch?v=VIDEO_ID)
|
||||
result = result.replace(
|
||||
/\[([^\]]*)\]\(https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([^)&]+)[^)]*\)/g,
|
||||
'<video-embed provider="youtube" video-id="$2"></video-embed>',
|
||||
VIDEO_PREPROCESS_PATTERNS.youtubeLink,
|
||||
(_match: string, _text: string, videoId: string) => createVideoEmbedTag('youtube', videoId),
|
||||
)
|
||||
|
||||
// YouTube short URLs: [Text](https://youtu.be/VIDEO_ID)
|
||||
result = result.replace(
|
||||
/\[([^\]]*)\]\(https?:\/\/youtu\.be\/([^?)]+)[^)]*\)/g,
|
||||
'<video-embed provider="youtube" video-id="$2"></video-embed>',
|
||||
VIDEO_PREPROCESS_PATTERNS.youtubeShortLink,
|
||||
(_match: string, _text: string, videoId: string) => createVideoEmbedTag('youtube', videoId),
|
||||
)
|
||||
|
||||
// Rumble autolinks: <https://rumble.com/embed/VIDEO_ID>
|
||||
result = result.replace(
|
||||
/<https?:\/\/rumble\.com\/embed\/([^>]+)>/g,
|
||||
'<video-embed provider="rumble" video-id="$1"></video-embed>',
|
||||
result = result.replace(VIDEO_PREPROCESS_PATTERNS.rumbleAutolink, (_, videoId: string) =>
|
||||
createVideoEmbedTag('rumble', videoId),
|
||||
)
|
||||
|
||||
// Rumble embed URLs: [Text](https://rumble.com/embed/VIDEO_ID)
|
||||
result = result.replace(
|
||||
/\[([^\]]*)\]\(https?:\/\/rumble\.com\/embed\/([^)]+)\)/g,
|
||||
'<video-embed provider="rumble" video-id="$2"></video-embed>',
|
||||
VIDEO_PREPROCESS_PATTERNS.rumbleLink,
|
||||
(_match: string, _text: string, videoId: string) => createVideoEmbedTag('rumble', videoId),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
113
lib/src/Components/TipTap/utils/videoPatterns.ts
Normal file
113
lib/src/Components/TipTap/utils/videoPatterns.ts
Normal file
@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Shared video URL patterns for YouTube and Rumble.
|
||||
* Used by both VideoEmbed extension and preprocessMarkdown utility.
|
||||
*/
|
||||
|
||||
export type VideoProvider = 'youtube' | 'rumble'
|
||||
|
||||
export interface VideoInfo {
|
||||
provider: VideoProvider
|
||||
videoId: string
|
||||
}
|
||||
|
||||
// YouTube video ID pattern: 11 characters (alphanumeric, dash, underscore)
|
||||
// We allow 10-12 for flexibility
|
||||
const YOUTUBE_VIDEO_ID_PATTERN = '[a-zA-Z0-9_-]{10,12}'
|
||||
|
||||
// Rumble video ID pattern: alphanumeric only
|
||||
const RUMBLE_VIDEO_ID_PATTERN = '[a-zA-Z0-9]+'
|
||||
|
||||
/**
|
||||
* Regex patterns for parsing video URLs (used for paste handling)
|
||||
*/
|
||||
export const VIDEO_URL_PATTERNS = {
|
||||
youtube: new RegExp(
|
||||
`^https?:\\/\\/(?:www\\.)?youtube\\.com\\/watch\\?v=(${YOUTUBE_VIDEO_ID_PATTERN})(?:&|$)`,
|
||||
),
|
||||
youtubeShort: new RegExp(`^https?:\\/\\/youtu\\.be\\/(${YOUTUBE_VIDEO_ID_PATTERN})(?:\\?|$)`),
|
||||
rumble: new RegExp(`^https?:\\/\\/rumble\\.com\\/embed\\/(${RUMBLE_VIDEO_ID_PATTERN})(?:\\/|$)`),
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Regex patterns for markdown tokenizer (used in TipTap extension)
|
||||
* These match autolinks: <https://...>
|
||||
*/
|
||||
export const VIDEO_AUTOLINK_PATTERNS = {
|
||||
youtube: new RegExp(
|
||||
`^<https?:\\/\\/(?:www\\.)?youtube\\.com\\/watch\\?v=(${YOUTUBE_VIDEO_ID_PATTERN})[^>]*>`,
|
||||
),
|
||||
youtubeShort: new RegExp(`^<https?:\\/\\/youtu\\.be\\/(${YOUTUBE_VIDEO_ID_PATTERN})[^>]*>`),
|
||||
rumble: new RegExp(`^<https?:\\/\\/rumble\\.com\\/embed\\/(${RUMBLE_VIDEO_ID_PATTERN})[^>]*>`),
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Regex patterns for preprocessing markdown (global replacement)
|
||||
* These match both autolinks <url> and markdown links [text](url)
|
||||
*/
|
||||
export const VIDEO_PREPROCESS_PATTERNS = {
|
||||
// Autolinks: <https://...>
|
||||
youtubeAutolink: /<https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([^>&]+)[^>]*>/g,
|
||||
youtubeShortAutolink: /<https?:\/\/youtu\.be\/([^>?]+)[^>]*>/g,
|
||||
rumbleAutolink: /<https?:\/\/rumble\.com\/embed\/([^>]+)>/g,
|
||||
|
||||
// Markdown links: [text](url)
|
||||
youtubeLink: /\[([^\]]*)\]\(https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([^)&]+)[^)]*\)/g,
|
||||
youtubeShortLink: /\[([^\]]*)\]\(https?:\/\/youtu\.be\/([^?)]+)[^)]*\)/g,
|
||||
rumbleLink: /\[([^\]]*)\]\(https?:\/\/rumble\.com\/embed\/([^)]+)\)/g,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Generates embed URLs for video providers
|
||||
*/
|
||||
export function getVideoEmbedUrl(provider: VideoProvider, videoId: string): string {
|
||||
// Sanitize videoId to only allow safe characters
|
||||
const safeVideoId = videoId.replace(/[^a-zA-Z0-9_-]/g, '')
|
||||
|
||||
switch (provider) {
|
||||
case 'youtube':
|
||||
return `https://www.youtube-nocookie.com/embed/${safeVideoId}`
|
||||
case 'rumble':
|
||||
return `https://rumble.com/embed/${safeVideoId}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the canonical URL for a video (used in markdown serialization)
|
||||
*/
|
||||
export function getVideoCanonicalUrl(provider: VideoProvider, videoId: string): string {
|
||||
switch (provider) {
|
||||
case 'youtube':
|
||||
return `https://www.youtube.com/watch?v=${videoId}`
|
||||
case 'rumble':
|
||||
return `https://rumble.com/embed/${videoId}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts video provider and ID from a URL
|
||||
*/
|
||||
export function parseVideoUrl(url: string): VideoInfo | null {
|
||||
let match = VIDEO_URL_PATTERNS.youtube.exec(url)
|
||||
if (match) {
|
||||
return { provider: 'youtube', videoId: match[1] }
|
||||
}
|
||||
|
||||
match = VIDEO_URL_PATTERNS.youtubeShort.exec(url)
|
||||
if (match) {
|
||||
return { provider: 'youtube', videoId: match[1] }
|
||||
}
|
||||
|
||||
match = VIDEO_URL_PATTERNS.rumble.exec(url)
|
||||
if (match) {
|
||||
return { provider: 'rumble', videoId: match[1] }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a video-embed HTML tag for preprocessing
|
||||
*/
|
||||
export function createVideoEmbedTag(provider: VideoProvider, videoId: string): string {
|
||||
return `<video-embed provider="${provider}" video-id="${videoId}"></video-embed>`
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user