From da69f198e6ce93468d6b94e7b3a4d4f3b7ed4c15 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 24 Nov 2025 22:36:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D1=88=D0=B5=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/docs/DocsContent.vue | 88 +++++++++++++++++-- .../src/components/editor/RichTextEditor.vue | 27 +++++- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/docs/DocsContent.vue b/frontend/src/components/docs/DocsContent.vue index 9d4cb90..6ebafb1 100644 --- a/frontend/src/components/docs/DocsContent.vue +++ b/frontend/src/components/docs/DocsContent.vue @@ -71,8 +71,7 @@
-
- +

Контент не добавлен

@@ -262,15 +262,38 @@ const formatContent = computed(() => { } } + // Конфигурация DOMPurify для разрешения медиа-контента + const sanitizeConfig = { + ADD_TAGS: ['video', 'source', 'img', 'iframe'], + ADD_ATTR: [ + 'controls', 'autoplay', 'loop', 'muted', 'poster', 'preload', 'playsinline', + 'src', 'alt', 'title', 'width', 'height', 'style', 'class', 'loading', + 'frameborder', 'allowfullscreen', 'allow' + ], + ALLOW_DATA_ATTR: true + }; + + // Проверяем, является ли контент HTML (содержит HTML теги) + const isHtml = /<[a-z][\s\S]*>/i.test(content); + // Проверяем, является ли контент markdown - const isMarkdown = /^#{1,6}\s|^\*\s|^\-\s|^\d+\.\s|```|\[.+\]\(.+\)|!\[.+\]\(.+\)/m.test(content); + const isMarkdown = !isHtml && /^#{1,6}\s|^\*\s|^\-\s|^\d+\.\s|```|\[.+\]\(.+\)|!\[.+\]\(.+\)/m.test(content); if (isMarkdown) { const rawHtml = marked.parse(content); - // Разрешаем теги video и их атрибуты для корректного отображения видео - let sanitizedHtml = DOMPurify.sanitize(rawHtml, { - ADD_TAGS: ['video', 'source'], - ADD_ATTR: ['controls', 'autoplay', 'loop', 'muted', 'poster', 'preload', 'playsinline'] + // Разрешаем теги video, source, img и их атрибуты для корректного отображения медиа + let sanitizedHtml = DOMPurify.sanitize(rawHtml, sanitizeConfig); + + // Преобразуем iframe с видео-файлами в тег video для корректного воспроизведения + // Quill вставляет видео как iframe, но для локальных файлов нужен тег video + sanitizedHtml = sanitizedHtml.replace(/]*?)src=["']([^"']+)["']([^>]*?)><\/iframe>/gi, (match, attrs1, url, attrs2) => { + // Проверяем, является ли это видео-файл из нашей системы (по URL) + if (url.includes('/api/uploads/media/') && url.includes('/file')) { + // Преобразуем в тег video для локальных видео-файлов + return ``; + } + // Оставляем iframe для внешних видео (YouTube, Vimeo и т.д.) + return match; }); // Еще раз удаляем заголовки h1 из HTML после парсинга markdown @@ -286,6 +309,33 @@ const formatContent = computed(() => { sanitizedHtml = sanitizedHtml.replace(/^\s*(|

\s*<\/p>)\s*/i, ''); sanitizedHtml = sanitizedHtml.trim(); + return sanitizedHtml; + } else if (isHtml) { + // Если контент уже в HTML формате, санитизируем его с сохранением медиа + let sanitizedHtml = DOMPurify.sanitize(content, sanitizeConfig); + + // Преобразуем iframe с видео-файлами в тег video для корректного воспроизведения + // Quill вставляет видео как iframe, но для локальных файлов нужен тег video + sanitizedHtml = sanitizedHtml.replace(/]*?)src=["']([^"']+)["']([^>]*?)><\/iframe>/gi, (match, attrs1, url, attrs2) => { + // Проверяем, является ли это видео-файл из нашей системы (по URL) + if (url.includes('/api/uploads/media/') && url.includes('/file')) { + // Преобразуем в тег video для локальных видео-файлов + return ``; + } + // Оставляем iframe для внешних видео (YouTube, Vimeo и т.д.) + return match; + }); + + // Удаляем заголовки h1 из HTML, если они совпадают с title + sanitizedHtml = sanitizedHtml.replace(/]*>([^<]*)<\/h1>/gi, (match, headerText) => { + const text = headerText.trim(); + if (text.toLowerCase() === title.toLowerCase()) { + return ''; // Удаляем заголовок + } + return match; // Оставляем заголовок + }); + + sanitizedHtml = sanitizedHtml.trim(); return sanitizedHtml; } else { // Для обычного текста также удаляем первую строку, если она совпадает с заголовком @@ -655,6 +705,30 @@ onMounted(() => { outline-offset: 2px; } +/* Стили для iframe в контенте (для видео) */ +.content-text :deep(iframe) { + max-width: 100%; + width: 100%; + height: auto; + min-height: 400px; + border-radius: 8px; + margin: 1.5rem 0; + display: block; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + background: #000; + border: none; +} + +.content-text :deep(iframe.ql-video) { + min-height: 400px; + aspect-ratio: 16 / 9; +} + +.content-text :deep(iframe:focus) { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + .file-preview { display: flex; flex-direction: column; diff --git a/frontend/src/components/editor/RichTextEditor.vue b/frontend/src/components/editor/RichTextEditor.vue index 1c28fe7..971f5e5 100644 --- a/frontend/src/components/editor/RichTextEditor.vue +++ b/frontend/src/components/editor/RichTextEditor.vue @@ -220,8 +220,17 @@ function handleVideoClick() { console.log('[RichTextEditor] Вставляем видео по URL:', fullUrl, 'в позицию:', range.index); - // Вставляем видео - quill.insertEmbed(range.index, 'video', fullUrl); + // Проверяем, является ли это локальный файл из нашей системы + const isLocalFile = fullUrl.includes('/api/uploads/media/') && fullUrl.includes('/file'); + + if (isLocalFile) { + // Для локальных файлов вставляем тег