mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2025-12-13 07:46:10 +00:00
youtube
This commit is contained in:
parent
b539046602
commit
9b6d22b843
1
lib/package-lock.json
generated
1
lib/package-lock.json
generated
@ -53,6 +53,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"yet-another-react-lightbox": "^3.21.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -141,6 +141,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"yet-another-react-lightbox": "^3.21.7"
|
||||
},
|
||||
"imports": {
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import { log } from 'node:console'
|
||||
|
||||
import { Color } from '@tiptap/extension-color'
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import { Link } from '@tiptap/extension-link'
|
||||
@ -11,7 +13,14 @@ import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import { TableRow } from '@tiptap/extension-table-row'
|
||||
import { TaskItem } from '@tiptap/extension-task-item'
|
||||
import { TaskList } from '@tiptap/extension-task-list'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import { Youtube } from '@tiptap/extension-youtube'
|
||||
import {
|
||||
EditorContent,
|
||||
useEditor,
|
||||
nodePasteRule,
|
||||
nodeInputRule,
|
||||
mergeAttributes,
|
||||
} from '@tiptap/react'
|
||||
import { StarterKit } from '@tiptap/starter-kit'
|
||||
import { MarkdownSerializer } from 'prosemirror-markdown'
|
||||
import { useEffect } from 'react'
|
||||
@ -76,6 +85,10 @@ export function RichTextEditor({
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
Color.configure({ types: ['textStyle', 'listItem'] }),
|
||||
CustomYoutube.configure({
|
||||
nocookie: true,
|
||||
allowFullscreen: true,
|
||||
}),
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
keepMarks: true,
|
||||
@ -177,23 +190,98 @@ const CustomImage = Image.extend({
|
||||
|
||||
export function getStyledMarkdown(editor: Editor): string {
|
||||
const { serializer } = editor.storage.markdown as { serializer: MarkdownSerializer }
|
||||
|
||||
const baseNodes = serializer.nodes as Record<string, NodeSerializerFn>
|
||||
const marks = serializer.marks
|
||||
|
||||
const customImage: NodeSerializerFn = (state, node) => {
|
||||
const { src, alt, title, style } = node.attrs as ImageAttrs
|
||||
|
||||
let tag = '<img src="' + src + '"'
|
||||
if (alt) tag += ' alt="' + alt + '"'
|
||||
if (title) tag += ' title="' + title + '"'
|
||||
if (style) tag += ' style="' + style + '"'
|
||||
tag += ' />'
|
||||
|
||||
state.write(tag)
|
||||
}
|
||||
|
||||
const customSerializer = new MarkdownSerializer({ ...baseNodes, image: customImage }, marks)
|
||||
const customYoutube: NodeSerializerFn = (state, node) => {
|
||||
const { src } = node.attrs as { src: string }
|
||||
|
||||
const match = src.match(
|
||||
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/,
|
||||
)
|
||||
const videoId = match?.[1]
|
||||
const nocookieUrl = `https://www.youtube-nocookie.com/embed/${videoId}`
|
||||
|
||||
let tag = '<div class="tw:w-full tw:aspect-video tw:overflow-hidden">'
|
||||
tag += `<iframe src="${nocookieUrl}" allowfullscreen class="tw-w-full tw-h-full" loading="lazy"></iframe>`
|
||||
tag += '</div>'
|
||||
state.write(tag)
|
||||
}
|
||||
|
||||
const customSerializer = new MarkdownSerializer(
|
||||
{
|
||||
...baseNodes,
|
||||
image: customImage,
|
||||
youtube: customYoutube,
|
||||
},
|
||||
marks,
|
||||
)
|
||||
|
||||
return customSerializer.serialize(editor.state.doc)
|
||||
}
|
||||
|
||||
const CustomYoutube = Youtube.extend({
|
||||
addPasteRules() {
|
||||
return [
|
||||
nodePasteRule({
|
||||
find: youtubePasteRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
return { src: match[1] }
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: youtubeInputRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
return { src: match[1] }
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const otherAttrs = { ...HTMLAttributes } as Record<string, string | number>
|
||||
const originalSrc = otherAttrs.src as string
|
||||
const match = originalSrc.match(
|
||||
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/,
|
||||
)
|
||||
const videoId = match?.[1]
|
||||
const nocookieUrl = `https://www.youtube-nocookie.com/embed/${videoId}`
|
||||
|
||||
delete otherAttrs.width
|
||||
delete otherAttrs.height
|
||||
|
||||
const iframeAttrs = {
|
||||
...otherAttrs,
|
||||
src: nocookieUrl,
|
||||
allowfullscreen: '',
|
||||
class: 'tw-w-full tw-h-full',
|
||||
loading: 'lazy',
|
||||
}
|
||||
|
||||
return [
|
||||
'div',
|
||||
{ class: 'tw:w-full tw:aspect-video tw:overflow-hidden' },
|
||||
['iframe', iframeAttrs],
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
const youtubePasteRegex =
|
||||
/(https?:\/\/(?:www\.)?youtube\.com\/watch\?v=[\w-]+|https?:\/\/youtu\.be\/[\w-]+)/g
|
||||
|
||||
const youtubeInputRegex =
|
||||
/(https?:\/\/(?:www\.)?youtube\.com\/watch\?v=[\w-]+|https?:\/\/youtu\.be\/[\w-]+)$/
|
||||
|
||||
@ -10,6 +10,7 @@ import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
import { useAddFilterTag } from '#components/Map/hooks/useFilter'
|
||||
import { useTags } from '#components/Map/hooks/useTags'
|
||||
@ -210,14 +211,57 @@ function truncateText(text, limit) {
|
||||
return truncated.trim()
|
||||
}
|
||||
|
||||
const sanitizeSchema = {
|
||||
export const sanitizeSchema = {
|
||||
...defaultSchema,
|
||||
|
||||
tagNames: [...(defaultSchema.tagNames ?? []), 'div', 'iframe'],
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
img: [
|
||||
// alle bisherigen Attribute, plus 'style'
|
||||
...(defaultSchema.attributes?.img ?? []),
|
||||
'style',
|
||||
div: [...(defaultSchema.attributes?.div ?? []), 'data-youtube-video'],
|
||||
iframe: [
|
||||
...(defaultSchema.attributes?.iframe ?? []),
|
||||
'src',
|
||||
'width',
|
||||
'height',
|
||||
'allowfullscreen',
|
||||
'autoplay',
|
||||
'disablekbcontrols',
|
||||
'enableiframeapi',
|
||||
'endtime',
|
||||
'ivloadpolicy',
|
||||
'loop',
|
||||
'modestbranding',
|
||||
'origin',
|
||||
'playlist',
|
||||
'rel',
|
||||
'start',
|
||||
],
|
||||
img: [...(defaultSchema.attributes?.img ?? []), 'style'],
|
||||
},
|
||||
|
||||
protocols: {
|
||||
...defaultSchema.protocols,
|
||||
src: [...(defaultSchema.protocols?.src ?? []), 'https'],
|
||||
},
|
||||
}
|
||||
|
||||
export function rehypeFilterYouTubeIframes() {
|
||||
return (tree: any) => {
|
||||
visit(tree, 'element', (node) => {
|
||||
if (node.tagName === 'iframe') {
|
||||
const src = String(node.properties?.src || '')
|
||||
// Nur echte YouTube-Embed-URLs zulassen
|
||||
if (
|
||||
!/^https:\/\/(?:www\.)?(?:youtube\.com|youtube-nocookie\.com)\/embed\/[A-Za-z0-9_-]+(?:\?.*)?$/.test(
|
||||
src,
|
||||
)
|
||||
) {
|
||||
// ersetze es durch einen leeren div
|
||||
node.tagName = 'div'
|
||||
node.properties = {}
|
||||
node.children = []
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user