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:
parent
6c4d4310a9
commit
9379ee46e6
27 changed files with 22198 additions and 3026 deletions
|
@ -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
16477
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
@ -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
9
public/arrow-right.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 119 KiB |
0
public/arrow-right.svg:Zone.Identifier
Normal file
0
public/arrow-right.svg:Zone.Identifier
Normal file
Binary file not shown.
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 669 B |
|
@ -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,
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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' %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
26
src/backend/views/pages/blocks/video.twig
Normal file
26
src/backend/views/pages/blocks/video.twig
Normal 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>
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -16,9 +16,9 @@
|
|||
<div class="greeting-content">
|
||||
{{ svg('frog') }}
|
||||
<p class="greeting-content__message">
|
||||
It’s 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>
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
|
|
126
src/frontend/js/classes/custom-image-tool.js
Normal file
126
src/frontend/js/classes/custom-image-tool.js
Normal 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] || '';
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
|
|
288
src/frontend/js/classes/text-color-tool.js
Normal file
288
src/frontend/js/classes/text-color-tool.js
Normal 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';
|
||||
}
|
||||
}
|
369
src/frontend/js/classes/text-size-tool.js
Normal file
369
src/frontend/js/classes/text-size-tool.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
240
src/frontend/js/classes/video-tool.js
Normal file
240
src/frontend/js/classes/video-tool.js
Normal 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>`;
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -30,3 +30,4 @@ a {
|
|||
text-decoration: none;
|
||||
color: inherit
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue