diff --git a/lib/package-lock.json b/lib/package-lock.json index b900ef90..573e9970 100644 --- a/lib/package-lock.json +++ b/lib/package-lock.json @@ -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": { diff --git a/lib/package.json b/lib/package.json index 8c050b00..b660a2a5 100644 --- a/lib/package.json +++ b/lib/package.json @@ -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": { diff --git a/lib/src/Components/Input/RichTextEditor.tsx b/lib/src/Components/Input/RichTextEditor.tsx index 45d7015f..78548003 100644 --- a/lib/src/Components/Input/RichTextEditor.tsx +++ b/lib/src/Components/Input/RichTextEditor.tsx @@ -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 const marks = serializer.marks - const customImage: NodeSerializerFn = (state, node) => { const { src, alt, title, style } = node.attrs as ImageAttrs - let tag = ' { + 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 = '
' + tag += `` + tag += '
' + 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 + 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-]+)$/ diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx index 9c831189..5add5edf 100644 --- a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx @@ -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 = [] + } + } + }) + } +}