1
0
Fork 0
mirror of https://github.com/codex-team/codex.docs.git synced 2025-07-19 05:09:41 +02:00

Initial commit

This commit is contained in:
exezzz 2025-05-26 08:17:24 +03:00
parent 6c4d4310a9
commit 9379ee46e6
27 changed files with 22198 additions and 3026 deletions

View file

@ -12,18 +12,14 @@ uploads:
accessKeyId: "my-access-key"
secretAccessKey: "my-secret-key"
frontend:
title: "CodeX Docs"
description: "Free Docs app powered by Editor.js ecosystemt"
title: "Справка"
description: "Справка по работе в Кабинетах"
startPage: ""
misprintsChatId: "12344564"
yandexMetrikaId: ""
carbon:
serve: ""
placement: ""
menu:
- "Guides"
- title: "CodeX"
uri: "https://codex.so"
auth:
password: secretpassword

16477
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -34,13 +34,13 @@
"@types/yargs": "^17.0.13",
"arg": "^5.0.2",
"cookie-parser": "^1.4.5",
"csurf": "^1.11.0",
"csurf": "^1.2.2",
"debug": "^4.3.2",
"express": "^4.17.1",
"file-type": "^16.5.4",
"fs-extra": "^10.1.0",
"http-errors": "^2.0.0",
"jsonwebtoken": "^8.5.1",
"jsonwebtoken": "^9.0.2",
"mime": "^3.0.0",
"mkdirp": "^1.0.4",
"mongodb": "^4.10.0",
@ -121,11 +121,11 @@
"mocha-sinon": "^2.1.2",
"module-dispatcher": "^2.0.0",
"normalize.css": "^8.0.1",
"nyc": "^13.1.0",
"nyc": "^17.1.0",
"postcss": "^8.4.7",
"postcss-apply": "^0.12.0",
"postcss-color-hex-alpha": "^8.0.3",
"postcss-color-mod-function": "^3.0.3",
"postcss-color-mod-function": "^4.1.1",
"postcss-custom-media": "^8.0.0",
"postcss-custom-properties": "^12.1.4",
"postcss-custom-selectors": "^6.0.0",
@ -133,7 +133,7 @@
"postcss-loader": "^6.2.1",
"postcss-media-minmax": "^5.0.0",
"postcss-nested": "^5.0.6",
"postcss-nested-ancestors": "^2.0.0",
"postcss-nested-ancestors": "^3.0.0",
"postcss-nesting": "^10.1.3",
"postcss-smart-import": "^0.7.6",
"rimraf": "^3.0.2",

9
public/arrow-right.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 119 KiB

View file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 669 B

Before After
Before After

View file

@ -33,6 +33,14 @@ const imageUploader = multer({
*/
const fileUploader = multer({
storage,
fileFilter: (req, file, cb) => {
// Разрешаем только видео и изображения
if (!/image|video/.test(file.mimetype)) {
cb(null, false);
return;
}
cb(null, true);
},
}).fields([ {
name: 'file',
maxCount: 1,

View file

@ -6,14 +6,13 @@
┬┴┬┴┤ ͜ʖ ͡°) ├┬┴┬┴
</h1>
<p>
Enter a password to access pages editing
Введите пароль для доступа к редактированию страниц
</p>
<p>
{{ header }}
</p>
<input type="hidden" name="_csrf" value={{ csrfToken }}>
<input type="password" name="password" placeholder="Password">
<input type="submit" value="Login">
<input type="password" name="password" placeholder="Пароль">
<input type="submit" value="Войти">
</form>
{% endblock %}

View file

@ -12,7 +12,7 @@
{% set attrNameForTextToCopy = 'data-text-to-copy' %}
{% set ariaLabel = ariaLabel ?? 'Copy to the Clipboard' %}
{% set ariaLabel = ariaLabel ?? 'Копировать в буфер обмена' %}
{% set mainTag = 'button' %}
{% set mainClass = 'copy-button' %}

View file

@ -5,7 +5,7 @@
<ul class="docs-header__menu">
{% if isAuthorized == true %}
<li class="docs-header__menu-add docs-header__menu-add--desktop">
{% include 'components/button.twig' with {label: 'Add page', icon: 'plus', size: 'small', url: '/page/new'} %}
{% include 'components/button.twig' with {label: 'Добавить страницу', icon: 'plus', size: 'small', url: '/page/new'} %}
</li>
<li class="docs-header__menu-add docs-header__menu-add--mobile">
{% include 'components/button.twig' with {icon: 'plus', size: 'small', url: '/page/new'} %}
@ -19,7 +19,7 @@
{% else %}
href="/page/{{ option._id }}"
{% endif %}>
{{ option.title | striptags }}
{{ option.title | raw }}
</a>
</li>
{% endfor %}

View file

@ -24,7 +24,7 @@ Usage example:
href="/{{ previousPage.uri }}"
>
<div class="{{ mainClass }}-direction">
previous
предыдущая
</div>
<div class="{{ mainClass }}-label">
{{ previousPage.title }}
@ -39,7 +39,7 @@ Usage example:
href="/{{ nextPage.uri }}"
>
<div class="{{ mainClass }}-direction">
next
следующая
</div>
<div class="{{ mainClass }}-label">
{{ nextPage.title }}
@ -47,4 +47,3 @@ Usage example:
</{{ tag }}>
{% endif %}
</div>

View file

@ -2,12 +2,12 @@
<div data-module="sidebar" class="docs-sidebar">
<div class="docs-sidebar__toggler">
{{ svg('menu') }} Table of contents
{{ svg('menu') }} Содержание
</div>
<aside class="docs-sidebar__content docs-sidebar__content--invisible">
<span class="docs-sidebar__search-wrapper">
<input class="docs-sidebar__search" type="text" placeholder="Search" />
<input class="docs-sidebar__search" type="text" placeholder="Поиск по статьям" />
</span>
{% for firstLevelPage in menu %}
<section class="docs-sidebar__section" data-id="{{firstLevelPage._id}}">
@ -43,17 +43,6 @@
</section>
{% endfor %}
<div class="docs-sidebar__logo">
<a class="docs-sidebar__logo-wrapper" href="https://github.com/codex-team/codex.docs">
<div class="docs-sidebar__logo-image">
{{ svg('aside-logo') }}
</div>
<p class="docs-sidebar__logo-caption">
Powered by CodeX Docs
</p>
</a>
</div>
</aside>
<div class="docs-sidebar__slider">

View file

@ -1,21 +1,22 @@
{% set classes = ['block-image__content'] %}
{% set width = data.width|default('100%') %}
{% set alignment = data.alignment|default('center') %}
{% if withBorder %}
{% set classes = classes|merge(['block-image__content--bordered']) %}
{% endif %}
{% if withBackground %}
{% set classes = classes|merge(['block-image__content--with-background']) %}
{% endif %}
<figure class="block-image">
<figure class="block-image" style="width: {{ width }}; max-width: 100%; margin:
{% if alignment == 'left' %}0 auto 0 0
{% elseif alignment == 'right' %}0 0 0 auto
{% else %}0 auto
{% endif %};">
<div class="{{ classes.join(' ') }}">
{% if file.mime and file.mime == 'video/mp4' %}
<video autoplay loop muted playsinline>
<video controls style="width: 100%;">
<source src="{{ file.url }}" type="video/mp4">
</video>
{% else %}
<img src="{{ file.url }}" alt="{{ caption ? caption | striptags : '' }}">
<img
src="{{ file.url }}"
alt="{{ caption ? caption | striptags : '' }}"
style="width: {{ width }};"
>
{% endif %}
</div>

View file

@ -0,0 +1,26 @@
{% set width = data.width|default('100%') %}
{% set height = data.height|default('auto') %}
{% set alignment = data.alignment|default('center') %}
<figure class="block-video" style="width: {{ width }}; max-width: 100%; margin:
{% if alignment == 'left' %}0 auto 0 0
{% elseif alignment == 'right' %}0 0 0 auto
{% else %}0 auto
{% endif %};">
<div class="{{ classes.join(' ') }}" style="padding-bottom: {{ data.aspectRatio ? (1/data.aspectRatio)*100 ~ '%' : '56.25%' }};">
<video
controls
style="width: 100%; height: 100%;"
{% if width %}width="{{ width }}"{% endif %}
{% if height %}height="{{ height }}"{% endif %}
>
<source src="{{ url }}" type="{{ data.mime|default('video/mp4') }}">
Ваш браузер не поддерживает видео тег.
</video>
</div>
{% if data.caption %}
<footer class="block-video__caption">
{{ data.caption|raw }}
</footer>
{% endif %}
</figure>

View file

@ -20,12 +20,12 @@
{% endif %}
<div class="select-wrapper">
{% if parentsChildrenOrdered is not empty %}
<label for="parent">Parent Page</label>
<label for="parent">Родительская страница</label>
{% else %}
<label for="parent">New Page at the</label>
<label for="parent">Новая страница в</label>
{% endif %}
<select id="parent" name="parent">
<option value="0">Root</option>
<option value="0">Корень</option>
{% for _page in pagesAvailableGrouped %}
{% if toString(_page._id) != toString(currentPageId) %}
<option value="{{ toString(_page._id) }}" {{ page is not empty and toString(page._parent) == toString(_page._id) ? 'selected' : ''}}>
@ -41,7 +41,7 @@
</div>
{% if parentsChildrenOrdered is not empty %}
<div class="select-wrapper">
<label for="above">Put Above</label>
<label for="above">Поместить выше</label>
<select id="above" name="above">
<option value="0">—</option>
{% for _page in parentsChildrenOrdered %}
@ -52,8 +52,8 @@
{% endif %}
{% if page is not empty %}
<div class="uri-input-wrapper">
<label for="uri-input">Alias</label>
<input type="text" id="uri-input" class="uri-input" name="uri-input" placeholder="URI (Optional)" value="{{ page.uri }}">
<label for="uri-input">Алиас</label>
<input type="text" id="uri-input" class="uri-input" name="uri-input" placeholder="URI (Опционально)" value="{{ page.uri }}">
</div>
{% endif %}
</div>
@ -63,9 +63,9 @@
</div>
<div class="writing-buttons">
{% include 'components/button.twig' with {label: 'Save changes', name: 'js-submit-save', icon: 'check'} %}
{% include 'components/button.twig' with {label: 'Сохранить изменения', name: 'js-submit-save', icon: 'check'} %}
{% if toString(page._id) is not empty %}
{% include 'components/button.twig' with {label: 'Delete doc', name: 'js-submit-remove', icon: 'trash', style: 'warning'} %}
{% include 'components/button.twig' with {label: 'Удалить документ', name: 'js-submit-remove', icon: 'trash', style: 'warning'} %}
{% endif %}
</div>

View file

@ -16,9 +16,9 @@
<div class="greeting-content">
{{ svg('frog') }}
<p class="greeting-content__message">
Its time to create the first page!
Пора создать первую страницу!
</p>
{% include '../components/button.twig' with {label: 'Add page', icon: 'plus', size: 'small', url: '/page/new'} %}
{% include '../components/button.twig' with {label: 'Добавить страницу', icon: 'plus', size: 'small', url: '/page/new'} %}
</div>
{% if config.yandexMetrikaId is not empty %}
<script type="text/javascript" >
@ -36,4 +36,3 @@
{% endif %}
</body>
</html>

View file

@ -5,7 +5,7 @@
<header class="page__header">
<div class="page__header-nav">
<a href="/" class="page__header-nav-item">
Documentation
Документация
</a>
{{ svg('arrow-right') }}
{% if page._parent %}
@ -20,10 +20,10 @@
{% endif %}
</div>
<time class="page__header-time">
Last edit {{ (page.body.time / 1000) | date("M d Y") }}
Изменена: {{ (page.body.time / 1000) | date("M d Y") }}
</time>
{% if isAuthorized == true %}
{% include 'components/button.twig' with {label: 'Edit', icon: 'pencil', size: 'small', url: '/page/edit/' ~ page._id, class: 'page__header-button'} %}
{% include 'components/button.twig' with {label: 'Редактировать', icon: 'pencil', size: 'small', url: '/page/edit/' ~ page._id, class: 'page__header-button'} %}
{% endif %}
</header>
<h1 class="page__title">
@ -36,7 +36,7 @@
{% for block in page.body.blocks %}
{# Skip first header, because it is already showed as a Title #}
{% if not (loop.first and block.type == 'header') %}
{% if block.type in ['paragraph', 'header', 'image', 'code', 'list', 'delimiter', 'table', 'warning', 'checklist', 'linkTool', 'raw', 'embed'] %}
{% if block.type in ['paragraph', 'header', 'image', 'code', 'list', 'delimiter', 'table', 'warning', 'checklist', 'linkTool', 'raw', 'embed','video'] %}
<div class="page__content-block">
{% include './blocks/' ~ block.type ~ '.twig' with block.data %}
</div>
@ -47,4 +47,23 @@
{% include '../components/navigator.twig' with {previousPage: previousPage, nextPage: nextPage} %}
</article>
<script>
// Замена английских месяцев на русские
document.addEventListener('DOMContentLoaded', function() {
const monthsMap = {
'Jan': 'Янв', 'Feb': 'Фев', 'Mar': 'Мар',
'Apr': 'Апр', 'May': 'Май', 'Jun': 'Июн',
'Jul': 'Июл', 'Aug': 'Авг', 'Sep': 'Сен',
'Oct': 'Окт', 'Nov': 'Ноя', 'Dec': 'Дек'
};
const timeElement = document.querySelector('.page__header-time');
if (timeElement) {
const text = timeElement.textContent;
const updatedText = text.replace(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)/,
match => monthsMap[match]);
timeElement.textContent = updatedText;
}
});
</script>
{% endblock %}

View file

@ -0,0 +1,126 @@
import ImageTool from '@editorjs/image';
export default class CustomImageTool extends ImageTool {
static get toolbox() {
return ImageTool.toolbox; // Возвращаем стандартную иконку
}
constructor({ data, api, config }) {
super({ data, api, config });
this.alignment = data.alignment || 'center';
this.width = data.width || '100%';
}
render() {
const container = super.render();
this._container = container;
// Добавляем обработчики после полного рендера
setTimeout(() => {
const wrapper = container.querySelector('.cdx-image');
if (wrapper) {
this._applyStyles(wrapper);
this._addControls(wrapper);
}
}, 100);
return container;
}
_applyStyles(wrapper) {
wrapper.style.display = 'block';
wrapper.style.width = 'fit-content';
wrapper.style.margin = this._getAlignmentMargin();
const img = wrapper.querySelector('img');
if (img) {
img.style.width = this.width;
img.style.height = 'auto';
}
}
_getAlignmentMargin() {
switch(this.alignment) {
case 'left': return '0 auto 0 0';
case 'right': return '0 0 0 auto';
default: return '0 auto';
}
}
_addControls(wrapper) {
// Добавляем кнопки выравнивания
const alignControls = document.createElement('div');
alignControls.className = 'image-align-controls';
['left', 'center', 'right'].forEach(align => {
const btn = document.createElement('button');
btn.innerHTML = this._getAlignmentIcon(align);
btn.dataset.align = align;
btn.classList.toggle('active', this.alignment === align);
btn.addEventListener('click', () => {
this.alignment = align;
wrapper.style.margin = this._getAlignmentMargin();
alignControls.querySelectorAll('button').forEach(b => {
b.classList.toggle('active', b.dataset.align === align);
});
});
alignControls.appendChild(btn);
});
// Добавляем ручку ресайза
const resizeHandle = document.createElement('div');
resizeHandle.className = 'image-resize-handle';
resizeHandle.innerHTML = '↔';
resizeHandle.addEventListener('mousedown', (e) => {
e.preventDefault();
const img = wrapper.querySelector('img');
if (img) this._initResize(img, e);
});
wrapper.appendChild(alignControls);
wrapper.appendChild(resizeHandle);
}
_initResize(element, startEvent) {
const startWidth = parseInt(element.style.width) || element.offsetWidth;
const startX = startEvent.clientX;
const maxWidth = this._container.offsetWidth;
const doResize = (e) => {
const newWidth = Math.max(200, Math.min(startWidth + (e.clientX - startX), maxWidth));
element.style.width = `${newWidth}px`;
};
const stopResize = () => {
document.removeEventListener('mousemove', doResize);
document.removeEventListener('mouseup', stopResize);
this.width = element.style.width;
};
document.addEventListener('mousemove', doResize);
document.addEventListener('mouseup', stopResize, { once: true });
}
save(blockContent) {
const savedData = super.save(blockContent);
return {
...savedData,
width: this.width,
alignment: this.alignment
};
}
_getAlignmentIcon(align) {
const icons = {
left: '⎸',
center: '⎸⎹',
right: ' |'
};
return icons[align] || '';
}
}

View file

@ -20,8 +20,11 @@ import Embed from '@editorjs/embed';
*/
import InlineCode from '@editorjs/inline-code';
import Marker from '@editorjs/marker';
/**
import { TextColorTool } from './text-color-tool.js';
import { TextSizeTool } from './text-size-tool.js';
import VideoTool from './video-tool.js';
import CustomImageTool from './custom-image-tool.js';
/*
* Class for working with Editor.js
*/
export default class Editor {
@ -35,6 +38,10 @@ export default class Editor {
*/
constructor(editorConfig = {}, options = {}) {
const defaultConfig = {
// Включаем поддержку отмены действий (CTRL+Z)
enableHistoryStack: true,
// Максимальное количество действий, которые можно отменить
maxHistoryLength: 30,
tools: {
header: {
class: Header,
@ -44,8 +51,40 @@ export default class Editor {
},
},
textColor: {
class: TextColorTool,
config: {
defaultColor: '#000000',
colors: [
'#FF0000', '#00FF00', '#0000FF',
'#FF00FF', '#00FFFF', '#FFA500',
'#000000', '#FFFFFF',
],
},
},
textSize: {
class: TextSizeTool,
config: {
defaultSize: '16px',
sizes: ['12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px']
}
},
video: {
class: VideoTool,
inlineToolbar: true,
config: {
uploader: {
byFile: '/api/transport/file', // Ваш эндпоинт
byUrl: '/api/transport/fetch' // Если нужна загрузка по URL
}
},
inlineToolbar: true
},
image: {
class: Image,
class: Image,//CustomImageTool
inlineToolbar: true,
config: {
types: 'image/*, video/mp4',

View file

@ -229,7 +229,7 @@ export default class TableOfContent {
this.nodes.wrapper = $.make('section', this.CSS.tocContainer);
const header = $.make('header', this.CSS.tocHeader, {
textContent: 'On this page',
textContent: 'На этой странице',
});
this.nodes.wrapper.appendChild(header);

View file

@ -0,0 +1,288 @@
class ColorPalette {
constructor(config) {
this.config = config;
this.element = document.createElement('div');
this.element.classList.add('color-palette');
this.element.style.display = 'none';
this.isOpen = false;
const savedColor = localStorage.getItem('editorjs-text-color');
this.config.currentColor = savedColor || this.config.defaultColor;
// Кнопка сброса
const resetBtn = document.createElement('button');
resetBtn.type = 'button';
resetBtn.textContent = '×';
resetBtn.classList.add('color-palette-reset');
resetBtn.addEventListener('click', () => {
this.config.onReset();
this.hide();
});
this.element.appendChild(resetBtn);
// Цвета
this.config.colors.forEach(color => {
const colorBtn = document.createElement('button');
colorBtn.type = 'button';
colorBtn.style.backgroundColor = color;
colorBtn.classList.add('color-palette-item');
// Подсветка выбранного цвета
if (color === this.config.currentColor) {
colorBtn.classList.add('active');
}
colorBtn.addEventListener('click', () => {
this.config.onSelect(color);
this.config.currentColor = color;
this.hide();
});
this.element.appendChild(colorBtn);
});
document.body.appendChild(this.element);
// Закрытие при клике вне палитры
document.addEventListener('click', (e) => {
if (this.isOpen && !this.element.contains(e.target) && e.target !== this.config.anchor) {
this.hide();
}
}, true);
}
toggle(anchor) {
this.config.anchor = anchor;
if (this.isOpen) {
this.hide();
} else {
this.show(anchor);
}
}
show(anchor) {
const rect = anchor.getBoundingClientRect();
this.element.style.position = 'absolute';
this.element.style.top = `${rect.bottom + window.scrollY + 5}px`;
this.element.style.left = `${rect.left + window.scrollX}px`;
this.element.style.display = 'flex';
this.isOpen = true;
}
hide() {
this.element.style.display = 'none';
this.isOpen = false;
}
}
export class TextColorTool {
constructor({ api, config }) {
this.api = api;
this.button = null;
this.palette = null;
this.state = false;
this.config = {
defaultColor: '#000000',
colors: ['#FF0000', '#00FF00', '#0000FF', '#FFFF00'],
...config
};
this.lastUsedColor = localStorage.getItem('editorjs-text-color') || this.config.defaultColor;
this.currentSelectionColor = null;
}
static get isInline() {
return true;
}
static get sanitize() {
return {
span: {
style: true,
class: true
}
};
}
render() {
this.button = document.createElement('button');
this.button.type = 'button';
this.updateButtonColor();
this.button.classList.add('ce-inline-tool');
this.palette = new ColorPalette({
colors: this.config.colors,
defaultColor: this.config.defaultColor,
currentColor: this.lastUsedColor,
anchor: this.button,
onSelect: (color) => {
this.applyColor(color);
this.lastUsedColor = color;
localStorage.setItem('editorjs-text-color', color);
this.updateButtonColor();
this.palette.hide();
},
onReset: () => {
this.removeColor();
this.lastUsedColor = this.config.defaultColor;
localStorage.removeItem('editorjs-text-color');
this.updateButtonColor();
this.palette.hide();
}
});
this.button.addEventListener('click', (e) => {
e.stopPropagation();
this.palette.toggle(this.button);
});
return this.button;
}
updateButtonColor() {
if (!this.button) return;
const displayColor = this.currentSelectionColor || this.lastUsedColor;
this.button.innerHTML = `
<svg width="20" height="20" viewBox="0 0 20 20">
<path fill="currentColor" d="M10 0C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/>
<path fill="${displayColor}" d="M10 5c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5z"/>
</svg>
`;
}
applyColor(color) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
const range = selection.getRangeAt(0);
const fragment = range.extractContents();
const tempDiv = document.createElement('div');
tempDiv.appendChild(fragment);
this._applyStyleToContent(tempDiv, color);
range.insertNode(tempDiv);
while (tempDiv.firstChild) {
tempDiv.parentNode.insertBefore(tempDiv.firstChild, tempDiv);
}
tempDiv.remove();
selection.removeAllRanges();
const newRange = document.createRange();
newRange.setStart(range.startContainer, range.startOffset);
newRange.setEnd(range.endContainer, range.endOffset);
selection.addRange(newRange);
this.lastUsedColor = color;
this.currentSelectionColor = color;
localStorage.setItem('editorjs-text-color', color);
this.updateButtonColor();
}
_applyStyleToContent(element, color) {
const nodeIterator = document.createNodeIterator(
element,
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT
);
let currentNode;
const nodesToProcess = [];
while (currentNode = nodeIterator.nextNode()) {
nodesToProcess.push(currentNode);
}
nodesToProcess.forEach(node => {
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
const parentSpan = node.parentElement?.closest('span[style*="color"]');
if (parentSpan) {
parentSpan.style.color = color;
} else {
const span = document.createElement('span');
span.style.color = color;
node.parentNode.insertBefore(span, node);
span.appendChild(node);
}
} else if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SPAN' && node.style.color) {
node.style.color = color;
}
});
}
removeColor() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const span = range.startContainer.parentElement?.closest('span[style*="color"]');
if (span) {
const text = document.createTextNode(span.textContent || '');
span.replaceWith(text);
selection.removeAllRanges();
const newRange = document.createRange();
newRange.selectNodeContents(text);
selection.addRange(newRange);
}
}
surround(range) {
this.applyColor(this.lastUsedColor);
}
checkState() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
this.currentSelectionColor = null;
return false;
}
const range = selection.getRangeAt(0);
const startNode = range.startContainer;
const endNode = range.endContainer;
const startColor = this.getTextColor(startNode);
const endColor = this.getTextColor(endNode);
if (startColor !== endColor) {
this.currentSelectionColor = null;
return false;
}
this.currentSelectionColor = startColor;
if (startColor && startColor !== this.lastUsedColor) {
this.lastUsedColor = startColor;
this.updateButtonColor();
}
return !!startColor;
}
getTextColor(node) {
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
const coloredSpan = element?.closest('span[style*="color"]');
if (!coloredSpan) {
const computedColor = window.getComputedStyle(element).color;
return computedColor === 'rgb(0, 0, 0)' ? '#000000' : null;
}
return this.rgbToHex(coloredSpan.style.color) || null;
}
rgbToHex(rgb) {
if (!rgb) return null;
const rgbMatch = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+))?\)$/);
if (!rgbMatch) return rgb;
const r = parseInt(rgbMatch[1]);
const g = parseInt(rgbMatch[2]);
const b = parseInt(rgbMatch[3]);
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
static get shortcut() {
return 'CMD+SHIFT+C';
}
}

View file

@ -0,0 +1,369 @@
class SizePalette {
constructor(config) {
this.config = config;
this.element = document.createElement('div');
this.element.classList.add('size-palette');
this.element.style.display = 'none';
this.isOpen = false;
this.savedSelection = null;
// Основной контейнер
const container = document.createElement('div');
container.classList.add('size-palette-container');
// Поле для ввода
const inputWrapper = document.createElement('div');
inputWrapper.classList.add('size-input-wrapper');
this.input = document.createElement('input');
this.input.type = 'text';
this.input.placeholder = 'Size';
this.input.classList.add('size-palette-input');
const applyBtn = document.createElement('button');
applyBtn.textContent = '✓';
applyBtn.classList.add('size-apply-btn');
inputWrapper.appendChild(this.input);
inputWrapper.appendChild(applyBtn);
container.appendChild(inputWrapper);
// Список стандартных размеров
const sizesList = document.createElement('div');
sizesList.classList.add('size-palette-list');
this.config.sizes.forEach(size => {
const sizeBtn = document.createElement('button');
sizeBtn.type = 'button';
sizeBtn.textContent = size;
sizeBtn.classList.add('size-palette-item');
sizeBtn.addEventListener('mousedown', (e) => {
e.preventDefault();
this.selectSize(size);
});
sizesList.appendChild(sizeBtn);
});
container.appendChild(sizesList);
this.element.appendChild(container);
document.body.appendChild(this.element);
// Обработчики событий
this.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.applyCustomSize();
});
applyBtn.addEventListener('mousedown', (e) => {
e.preventDefault();
this.applyCustomSize();
});
// Обработчик клика вне палитры
this.clickOutsideHandler = (e) => {
if (this.isOpen && !this.element.contains(e.target)) {
this.hide();
this.config.onClose();
}
};
document.addEventListener('mousedown', this.clickOutsideHandler, true);
}
saveSelection() {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
this.savedSelection = selection.getRangeAt(0).cloneRange();
}
}
restoreSelection() {
if (this.savedSelection) {
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(this.savedSelection);
}
}
selectSize(size) {
this.restoreSelection();
this.config.onSelect(size);
this.hide();
// Не закрываем тулбар после выбора размера
// this.config.onClose();
}
applyCustomSize() {
const value = this.input.value.trim();
if (value) {
const size = value.endsWith('px') ? value : `${value}px`;
this.selectSize(size);
}
}
toggle(anchor) {
if (this.isOpen) {
this.hide();
this.config.onClose();
} else {
this.saveSelection();
this.show(anchor);
}
}
show(anchor) {
const rect = anchor.getBoundingClientRect();
const scrollY = window.scrollY || document.documentElement.scrollTop;
const scrollX = window.scrollX || document.documentElement.scrollLeft;
this.element.style.position = 'absolute';
this.element.style.top = `${rect.bottom + scrollY + 5}px`;
this.element.style.left = `${rect.left + scrollX}px`;
this.element.style.display = 'block';
this.isOpen = true;
this.input.focus();
}
hide() {
this.element.style.display = 'none';
this.isOpen = false;
this.input.value = '';
}
destroy() {
document.removeEventListener('mousedown', this.clickOutsideHandler, true);
this.element.remove();
}
}
export class TextSizeTool {
constructor({ api, config }) {
this.api = api;
this.button = null;
this.palette = null;
this.config = {
defaultSize: '16px',
sizes: ['12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px'],
...config
};
this.currentSize = this.config.defaultSize;
this.isToolbarOpen = false;
}
static get isInline() {
return true;
}
static get sanitize() {
return {
span: {
style: true,
class: true
}
};
}
render() {
this.button = document.createElement('button');
this.button.type = 'button';
this.updateButtonContent();
this.button.classList.add('ce-inline-tool');
this.palette = new SizePalette({
sizes: this.config.sizes,
anchor: this.button,
onSelect: (size) => {
// Применяем размер
this.applySize(size);
// Принудительно обновляем содержимое кнопки после применения размера
this.api.toolbar.close();
setTimeout(() => {
this.api.toolbar.open();
this.updateButtonContent();
}, 10);
},
onClose: () => {
// Закрываем тулбар только если палитра была закрыта без выбора размера
if (this.isToolbarOpen) {
this.closeToolbar();
}
}
});
this.button.addEventListener('click', (e) => {
e.stopPropagation();
this.palette.toggle(this.button);
this.isToolbarOpen = !this.isToolbarOpen;
});
return this.button;
}
closeToolbar() {
if (this.isToolbarOpen) {
// Обновляем содержимое кнопки перед закрытием тулбара
this.updateButtonContent();
this.api.toolbar.close();
this.isToolbarOpen = false;
}
}
updateButtonContent() {
// Извлекаем числовое значение размера для SVG
const sizeValue = parseInt(this.currentSize);
this.button.innerHTML = `
<svg width="20" height="20" viewBox="0 0 20 20">
<text x="2" y="15" font-size="12" fill="currentColor">A</text>
<text x="8" y="15" font-size="${Math.min(16, sizeValue)}" fill="currentColor">A</text>
<text x="16" y="15" font-size="10" fill="currentColor">A</text>
</svg>
<span class="size-label">${this.currentSize}</span>
`;
}
applySize(size) {
this.palette.restoreSelection();
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
const range = selection.getRangeAt(0);
// Создаем временный элемент для хранения выделенного содержимого
const fragment = range.extractContents();
const tempDiv = document.createElement('div');
tempDiv.appendChild(fragment);
// Применяем стиль ко всему содержимому
this._applyStyleToContent(tempDiv, size);
// Вставляем обработанное содержимое обратно
range.insertNode(tempDiv);
// Перемещаем содержимое из временного div обратно в документ
while (tempDiv.firstChild) {
tempDiv.parentNode.insertBefore(tempDiv.firstChild, tempDiv);
}
tempDiv.remove();
// Восстанавливаем выделение
selection.removeAllRanges();
const newRange = document.createRange();
newRange.setStart(range.startContainer, range.startOffset);
newRange.setEnd(range.endContainer, range.endOffset);
selection.addRange(newRange);
// Сохраняем текущий размер
this.currentSize = size;
this.updateButtonContent();
}
/**
* Получает все элементы, которые будут затронуты изменением
* @param {Range} range - диапазон выделения
* @returns {Array} массив элементов
* @private
*/
_getAffectedElements(range) {
const elements = [];
const container = range.commonAncestorContainer;
if (container.nodeType === Node.ELEMENT_NODE) {
// Если контейнер - элемент, добавляем все его дочерние элементы в диапазоне
const nodeIterator = document.createNodeIterator(
container,
NodeFilter.SHOW_ELEMENT,
{ acceptNode: (node) => range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT }
);
let currentNode;
while (currentNode = nodeIterator.nextNode()) {
elements.push(currentNode);
}
} else if (container.nodeType === Node.TEXT_NODE) {
// Если контейнер - текстовый узел, добавляем его родительский элемент
elements.push(container.parentElement);
}
return elements;
}
/**
* Рекурсивно применяет стиль размера ко всему содержимому
* @param {HTMLElement} element - элемент, к содержимому которого нужно применить стиль
* @param {string} size - размер шрифта
* @private
*/
_applyStyleToContent(element, size) {
const nodeIterator = document.createNodeIterator(
element,
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT
);
let currentNode;
const nodesToProcess = [];
// Собираем все узлы
while (currentNode = nodeIterator.nextNode()) {
nodesToProcess.push(currentNode);
}
// Обрабатываем каждый узел
nodesToProcess.forEach(node => {
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
// Для текстовых узлов с непустым содержимым
const parentSpan = node.parentElement?.closest('span[style*="font-size"]');
if (parentSpan) {
// Если уже есть span с размером, обновляем его
parentSpan.style.fontSize = size;
} else {
// Иначе создаем новый span
const span = document.createElement('span');
span.style.fontSize = size;
node.parentNode.insertBefore(span, node);
span.appendChild(node);
}
} else if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SPAN' && node.style.fontSize) {
// Для span с заданным размером обновляем размер
node.style.fontSize = size;
}
});
}
surround(range) {
this.applySize(this.currentSize);
}
checkState() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return false;
const node = selection.getRangeAt(0).startContainer.parentElement;
const sizedSpan = node?.closest('span[style*="font-size"]');
if (sizedSpan) {
// Если найден элемент с заданным размером шрифта
this.currentSize = sizedSpan.style.fontSize;
this.updateButtonContent();
return true;
} else {
// Если выделение переместилось на текст без заданного размера шрифта,
// возвращаем размер по умолчанию
if (this.currentSize !== this.config.defaultSize) {
this.currentSize = this.config.defaultSize;
this.updateButtonContent();
}
return false;
}
}
static get shortcut() {
return 'CMD+SHIFT+S';
}
destroy() {
if (this.palette) {
this.palette.destroy();
}
}
}

View file

@ -0,0 +1,240 @@
export default class VideoTool {
static get toolbox() {
return {
icon: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M16.25 3.75H3.75C2.36929 3.75 1.25 4.86929 1.25 6.25V13.75C1.25 15.1307 2.36929 16.25 3.75 16.25H16.25C17.6307 16.25 18.75 15.1307 18.75 13.75V6.25C18.75 4.86929 17.6307 3.75 16.25 3.75Z" stroke="currentColor" stroke-width="1.5"/><path d="M8.125 6.875L13.125 10L8.125 13.125V6.875Z" fill="currentColor"/></svg>`,
title: 'Video',
};
}
constructor({ data, api, config }) {
this.data = data || {};
this.api = api;
this.config = config || {};
this.wrapper = null;
this.alignment = data.alignment || 'center';
this.width = data.width || '100%';
// Привязываем контекст для обработчиков
this._handleFileUpload = this._handleFileUpload.bind(this);
this._initResize = this._initResize.bind(this);
this._handleUrlSubmit = this._handleUrlSubmit.bind(this); // Added this binding
}
render() {
this.wrapper = document.createElement('div');
this.wrapper.classList.add('video-tool');
if (this.data.url) {
this._createVideoElement(this.data.url, this.data.caption || '');
} else {
this._createUploadForm();
}
return this.wrapper;
}
_createUploadForm() {
this.wrapper.innerHTML = `
<div class="video-upload-tabs">
<div class="tabs-header">
<button class="tab-button active" data-tab="upload">Upload</button>
<button class="tab-button" data-tab="embed">Embed URL</button>
</div>
<div class="tab-content active" data-tab="Загрузить с ПК">
<label class="video-upload-button">
<input type="file" accept="video/mp4,video/webm,video/ogg" class="video-file-input">
<span>Select Video File</span>
</label>
</div>
<div class="tab-content" data-tab="Вставить ссылку">
<div class="embed-form">
<select class="embed-service">
<option value="youtube">YouTube</option>
<option value="rutube">Rutube</option>
</select>
<input type="text" class="embed-url" placeholder="Paste video URL...">
<button class="embed-submit">Вставить видео</button>
</div>
</div>
</div>
`;
// Tab switching
this.wrapper.querySelectorAll('.tab-button').forEach(btn => {
btn.addEventListener('click', () => {
this.wrapper.querySelectorAll('.tab-button, .tab-content').forEach(el => {
el.classList.toggle('active', el.dataset.tab === btn.dataset.tab);
});
});
});
// File upload handler
this.wrapper.querySelector('.video-file-input')
.addEventListener('change', this._handleFileUpload);
// URL embed handler
this.wrapper.querySelector('.embed-submit')
.addEventListener('click', this._handleUrlSubmit);
}
async _handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
if (!this.config.uploader || !this.config.uploader.byFile) {
this._showError('File upload is not configured');
return;
}
try {
const url = await this._uploadFile(file);
this.type = 'file';
this.data = { url, type: 'file' };
his._createVideoElement(url, '');
} catch (error) {
this._showError('File upload failed: ' + error.message);
}
}
_handleUrlSubmit() {
const wrapper = this.wrapper; // Store reference to wrapper
const service = wrapper.querySelector('.embed-service').value;
const url = wrapper.querySelector('.embed-url').value.trim();
if (!url) {
this._showError('Please enter a valid URL');
return;
}
try {
const embedUrl = this._parseEmbedUrl(service, url);
this.type = service;
this.data = { url: embedUrl, type: service };
this._createVideoElement(embedUrl, '');
} catch (error) {
this._showError('Invalid video URL: ' + error.message);
}
}
_parseEmbedUrl(service, url) {
// YouTube
if (service === 'youtube') {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = url.match(regExp);
if (match && match[2].length === 11) {
return `https://www.youtube.com/embed/${match[2]}`;
}
}
// Rutube
if (service === 'rutube') {
const regExp = /rutube\.ru\/video\/([a-z0-9]+)/i;
const match = url.match(regExp);
if (match && match[1]) {
return `https://rutube.ru/play/embed/${match[1]}`;
}
}
throw new Error('Invalid URL');
}
_createVideoElement() {
this.wrapper.innerHTML = '';
const container = document.createElement('div');
container.className = 'video-container';
container.style.margin = this._getAlignmentMargin();
container.style.width = this.width;
if (this.type === 'file') {
const video = document.createElement('video');
video.src = this.data.url;
video.controls = true;
video.style.width = '100%';
container.appendChild(video);
}
else if (this.type === 'youtube' || this.type === 'rutube') {
const iframe = document.createElement('iframe');
iframe.src = this.data.url;
iframe.frameBorder = '0';
iframe.allowFullscreen = true;
iframe.style.width = '100%';
iframe.style.height = '400px';
container.appendChild(iframe);
}
this._addResizeHandle(container);
this._addCaption(container);
this.wrapper.appendChild(container);
}
async _uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(this.config.uploader.byFile, {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Upload failed');
const data = await response.json();
return data.file.url;
}
_addResizeHandle() {
const container = this.wrapper.querySelector('.video-container');
const video = container.querySelector('video');
const handle = document.createElement('div');
handle.className = 'resize-handle';
handle.innerHTML = '↔';
handle.addEventListener('mousedown', this._initResize);
container.appendChild(handle);
}
_initResize(e) {
e.preventDefault();
const video = this.wrapper.querySelector('video');
const startWidth = parseInt(video.style.width) || video.offsetWidth;
const startX = e.clientX;
const doResize = (e) => {
const newWidth = Math.max(200, startWidth + (e.clientX - startX));
video.style.width = `${newWidth}px`;
};
const stopResize = () => {
document.removeEventListener('mousemove', doResize);
document.removeEventListener('mouseup', stopResize);
this.width = video.style.width;
};
document.addEventListener('mousemove', doResize);
document.addEventListener('mouseup', stopResize, { once: true });
}
save(blockContent) {
const video = blockContent.querySelector('video');
const caption = blockContent.querySelector('.video-caption');
return {
url: video?.src || '',
caption: caption?.innerHTML || '',
width: video?.style.width || '100%',
alignment: this.alignment
};
}
_getAlignmentMargin() {
switch(this.alignment) {
case 'left': return '0 auto 0 0';
case 'right': return '0 0 0 auto';
default: return '0 auto';
}
}
_showError(message) {
this.wrapper.innerHTML = `<div class="video-error">${message}</div>`;
}
}

View file

@ -233,7 +233,7 @@
&__section-title--active,
&__section-list-item--active {
background: linear-gradient(270deg, #129bff 0%, #8a53ff 100%);
background: linear-gradient(270deg, #ff8812 0%, #f50000 100%);
color: white;
@media (--can-hover) {

View file

@ -66,6 +66,10 @@
}
}
.ce-paragraph[style*="color"] {
padding: 2px 5px;
}
.ce-header {
@apply --text-header;
padding: 0 0 10px;
@ -118,3 +122,427 @@
.cdx-settings-button[data-tune="stretched"] {
display: none;
}
/* Палитра цветов */
.color-palette {
background: white;
border: 1px solid #eaeaea;
border-radius: 4px;
padding: 5px;
gap: 5px;
flex-wrap: wrap;
width: 120px;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: none;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
/* Video styles */
.video-block {
margin: 1rem 0;
position: relative;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
height: 0;
overflow: hidden;
}
.video-iframe,
.video-player {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
.video-error {
color: #ff0000;
padding: 1rem;
background: #ffecec;
border-radius: 4px;
margin: 1rem 0;
}
.color-palette-item {
width: 20px;
height: 20px;
border: 1px solid #ddd;
border-radius: 50%;
cursor: pointer;
}
.color-palette-item.active {
border: 2px solid #000;
transform: scale(1.1);
}
.color-palette-reset {
width: 100%;
padding: 2px;
margin-bottom: 5px;
background: #f5f5f5;
border: none;
cursor: pointer;
}
.ce-inline-tool--active {
background: rgba(0,0,0,0.1);
}
/* Активная кнопка в тулбаре */
.ce-inline-tool--active svg {
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.3));
}
.ce-inline-tool svg path:last-child {
transition: fill 0.2s ease;
}
/* Стили для палитры размеров */
.size-palette {
background: white;
border: 1px solid #eaeaea;
border-radius: 6px;
padding: 6px;
width: 90px; /* Уменьшенная ширина */
max-height: 132px;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: none;
overflow: hidden;
animation: fadeIn 0.2s ease;
}
.size-palette-container {
display: flex;
flex-direction: column;
gap: 4px; /* Уменьшенный gap */
}
.size-input-wrapper {
display: flex;
gap: 4px;
align-items: center;
}
.size-palette-input {
flex: 1;
padding: 4px 6px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 12px;
min-width: 0;
width: 40px; /* Фиксированная ширина поля ввода */
}
.size-apply-btn {
padding: 0 6px;
height: 26px;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.size-palette-list {
max-height: 90px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px; /* Минимальный gap между элементами */
}
.size-palette-item {
padding: 4px 6px;
text-align: center;
background: none;
border: none;
border-radius: 3px;
font-size: 12px;
cursor: pointer;
margin: 0;
line-height: 1.2;
}
.size-palette-item:hover {
background: #f5f5f5;
}
/* Кнопка в тулбаре */
.ce-inline-tool .size-label {
margin-left: 4px;
font-size: 12px;
color: #666;
}
/* Иконка в тулбаре */
.ce-inline-tool svg text {
font-family: Arial, sans-serif;
font-weight: bold;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
.video-loading {
padding: 20px;
text-align: center;
color: #666;
}
.video-error {
padding: 20px;
color: #d32f2f;
background: #ffebee;
border-radius: 4px;
}
.retry-button {
margin-top: 10px;
padding: 5px 10px;
background: #1976d2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.video-tool {
position: relative;
margin: 1rem 0;
}
.video-tool__container {
position: relative;
display: block;
max-width: 100%;
margin: 1rem auto;
}
.video-tool__video {
display: block;
max-width: 100%;
height: auto;
}
/* Ручки ресайза */
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
background: rgba(0,0,0,0.7);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: nwse-resize;
opacity: 0;
transition: opacity 0.2s;
z-index: 10;
}
.video-container,
.cdx-image {
position: relative;
display: block;
width: fit-content;
margin: 1rem auto;
max-width: 100%;
}
.cdx-image img {
display: block;
max-width: 100%;
height: auto;
transition: width 0.2s ease;
}
.block-video {
margin: 1.5rem auto;
max-width: 100%;
}
.block-video__content {
position: relative;
width: 100%;
height: 0;
overflow: hidden;
background: #000;
}
.block-video__content video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.block-video__caption {
margin-top: 0.75rem;
font-size: 0.95rem;
color: #666;
text-align: center;
line-height: 1.4;
}
/* Общие стили для выравнивания */
.block-image, .block-video {
clear: both;
}
/* Стили для кнопок выравнивания */
.image-alignment-controls,
.video-alignment-controls {
position: absolute;
top: 5px;
right: 5px;
display: flex;
gap: 5px;
z-index: 10;
background: rgba(255,255,255,0.8);
padding: 3px;
border-radius: 3px;
}
.image-align-controls button,
.video-align-controls button {
border: none;
background: none;
cursor: pointer;
padding: 3px 5px;
border-radius: 3px;
}
.image-align-controls button.active,
.video-align-controls button.active {
background: #3498db;
color: white;
}
.image-resize-handle,
.video-resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
background: rgba(0,0,0,0.7);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: nwse-resize;
opacity: 0;
transition: opacity 0.2s;
z-index: 10;
}
.video-container:hover .resize-handle,
.cdx-image:hover .resize-handle,
.video-container:hover .alignment-controls,
.cdx-image:hover .alignment-controls {
opacity: 1;
}
/* Элементы управления выравниванием */
.alignment-controls {
position: absolute;
top: 5px;
right: 5px;
display: flex;
gap: 5px;
z-index: 10;
background: rgba(255,255,255,0.8);
padding: 3px;
border-radius: 3px;
}
.alignment-controls button {
border: none;
background: none;
cursor: pointer;
padding: 3px 5px;
border-radius: 3px;
}
.alignment-controls button.active {
background: #3498db;
color: white;
}
.video-upload-tabs {
width: 100%;
max-width: 500px;
margin: 0 auto;
}
.tabs-header {
display: flex;
border-bottom: 1px solid #ddd;
}
.tab-button {
padding: 10px 15px;
background: none;
border: none;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tab-button.active {
border-bottom-color: #3498db;
font-weight: bold;
}
.tab-content {
display: none;
padding: 15px 0;
}
.tab-content.active {
display: block;
}
.embed-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.embed-form select,
.embed-form input {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.embed-form button {
padding: 8px 15px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.video-container iframe {
border: none;
min-height: 400px;
}

View file

@ -30,3 +30,4 @@ a {
text-decoration: none;
color: inherit
}

7071
yarn.lock

File diff suppressed because it is too large Load diff