mirror of
https://github.com/codex-team/codex.docs.git
synced 2025-07-20 05:39: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"
|
accessKeyId: "my-access-key"
|
||||||
secretAccessKey: "my-secret-key"
|
secretAccessKey: "my-secret-key"
|
||||||
frontend:
|
frontend:
|
||||||
title: "CodeX Docs"
|
title: "Справка"
|
||||||
description: "Free Docs app powered by Editor.js ecosystemt"
|
description: "Справка по работе в Кабинетах"
|
||||||
startPage: ""
|
startPage: ""
|
||||||
misprintsChatId: "12344564"
|
misprintsChatId: "12344564"
|
||||||
yandexMetrikaId: ""
|
yandexMetrikaId: ""
|
||||||
carbon:
|
carbon:
|
||||||
serve: ""
|
serve: ""
|
||||||
placement: ""
|
placement: ""
|
||||||
menu:
|
|
||||||
- "Guides"
|
|
||||||
- title: "CodeX"
|
|
||||||
uri: "https://codex.so"
|
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
password: secretpassword
|
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",
|
"@types/yargs": "^17.0.13",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
"cookie-parser": "^1.4.5",
|
"cookie-parser": "^1.4.5",
|
||||||
"csurf": "^1.11.0",
|
"csurf": "^1.2.2",
|
||||||
"debug": "^4.3.2",
|
"debug": "^4.3.2",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"file-type": "^16.5.4",
|
"file-type": "^16.5.4",
|
||||||
"fs-extra": "^10.1.0",
|
"fs-extra": "^10.1.0",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mime": "^3.0.0",
|
"mime": "^3.0.0",
|
||||||
"mkdirp": "^1.0.4",
|
"mkdirp": "^1.0.4",
|
||||||
"mongodb": "^4.10.0",
|
"mongodb": "^4.10.0",
|
||||||
|
@ -121,11 +121,11 @@
|
||||||
"mocha-sinon": "^2.1.2",
|
"mocha-sinon": "^2.1.2",
|
||||||
"module-dispatcher": "^2.0.0",
|
"module-dispatcher": "^2.0.0",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"nyc": "^13.1.0",
|
"nyc": "^17.1.0",
|
||||||
"postcss": "^8.4.7",
|
"postcss": "^8.4.7",
|
||||||
"postcss-apply": "^0.12.0",
|
"postcss-apply": "^0.12.0",
|
||||||
"postcss-color-hex-alpha": "^8.0.3",
|
"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-media": "^8.0.0",
|
||||||
"postcss-custom-properties": "^12.1.4",
|
"postcss-custom-properties": "^12.1.4",
|
||||||
"postcss-custom-selectors": "^6.0.0",
|
"postcss-custom-selectors": "^6.0.0",
|
||||||
|
@ -133,7 +133,7 @@
|
||||||
"postcss-loader": "^6.2.1",
|
"postcss-loader": "^6.2.1",
|
||||||
"postcss-media-minmax": "^5.0.0",
|
"postcss-media-minmax": "^5.0.0",
|
||||||
"postcss-nested": "^5.0.6",
|
"postcss-nested": "^5.0.6",
|
||||||
"postcss-nested-ancestors": "^2.0.0",
|
"postcss-nested-ancestors": "^3.0.0",
|
||||||
"postcss-nesting": "^10.1.3",
|
"postcss-nesting": "^10.1.3",
|
||||||
"postcss-smart-import": "^0.7.6",
|
"postcss-smart-import": "^0.7.6",
|
||||||
"rimraf": "^3.0.2",
|
"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({
|
const fileUploader = multer({
|
||||||
storage,
|
storage,
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
// Разрешаем только видео и изображения
|
||||||
|
if (!/image|video/.test(file.mimetype)) {
|
||||||
|
cb(null, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
}).fields([ {
|
}).fields([ {
|
||||||
name: 'file',
|
name: 'file',
|
||||||
maxCount: 1,
|
maxCount: 1,
|
||||||
|
|
|
@ -6,14 +6,13 @@
|
||||||
┬┴┬┴┤ ͜ʖ ͡°) ├┬┴┬┴
|
┬┴┬┴┤ ͜ʖ ͡°) ├┬┴┬┴
|
||||||
</h1>
|
</h1>
|
||||||
<p>
|
<p>
|
||||||
Enter a password to access pages editing
|
Введите пароль для доступа к редактированию страниц
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{{ header }}
|
{{ header }}
|
||||||
</p>
|
</p>
|
||||||
<input type="hidden" name="_csrf" value={{ csrfToken }}>
|
<input type="hidden" name="_csrf" value={{ csrfToken }}>
|
||||||
<input type="password" name="password" placeholder="Password">
|
<input type="password" name="password" placeholder="Пароль">
|
||||||
<input type="submit" value="Login">
|
<input type="submit" value="Войти">
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
{% set attrNameForTextToCopy = 'data-text-to-copy' %}
|
{% set attrNameForTextToCopy = 'data-text-to-copy' %}
|
||||||
|
|
||||||
{% set ariaLabel = ariaLabel ?? 'Copy to the Clipboard' %}
|
{% set ariaLabel = ariaLabel ?? 'Копировать в буфер обмена' %}
|
||||||
|
|
||||||
{% set mainTag = 'button' %}
|
{% set mainTag = 'button' %}
|
||||||
{% set mainClass = 'copy-button' %}
|
{% set mainClass = 'copy-button' %}
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
<ul class="docs-header__menu">
|
<ul class="docs-header__menu">
|
||||||
{% if isAuthorized == true %}
|
{% if isAuthorized == true %}
|
||||||
<li class="docs-header__menu-add docs-header__menu-add--desktop">
|
<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>
|
||||||
<li class="docs-header__menu-add docs-header__menu-add--mobile">
|
<li class="docs-header__menu-add docs-header__menu-add--mobile">
|
||||||
{% include 'components/button.twig' with {icon: 'plus', size: 'small', url: '/page/new'} %}
|
{% include 'components/button.twig' with {icon: 'plus', size: 'small', url: '/page/new'} %}
|
||||||
|
@ -19,7 +19,7 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
href="/page/{{ option._id }}"
|
href="/page/{{ option._id }}"
|
||||||
{% endif %}>
|
{% endif %}>
|
||||||
{{ option.title | striptags }}
|
{{ option.title | raw }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -24,7 +24,7 @@ Usage example:
|
||||||
href="/{{ previousPage.uri }}"
|
href="/{{ previousPage.uri }}"
|
||||||
>
|
>
|
||||||
<div class="{{ mainClass }}-direction">
|
<div class="{{ mainClass }}-direction">
|
||||||
previous
|
предыдущая
|
||||||
</div>
|
</div>
|
||||||
<div class="{{ mainClass }}-label">
|
<div class="{{ mainClass }}-label">
|
||||||
{{ previousPage.title }}
|
{{ previousPage.title }}
|
||||||
|
@ -39,7 +39,7 @@ Usage example:
|
||||||
href="/{{ nextPage.uri }}"
|
href="/{{ nextPage.uri }}"
|
||||||
>
|
>
|
||||||
<div class="{{ mainClass }}-direction">
|
<div class="{{ mainClass }}-direction">
|
||||||
next
|
следующая
|
||||||
</div>
|
</div>
|
||||||
<div class="{{ mainClass }}-label">
|
<div class="{{ mainClass }}-label">
|
||||||
{{ nextPage.title }}
|
{{ nextPage.title }}
|
||||||
|
@ -47,4 +47,3 @@ Usage example:
|
||||||
</{{ tag }}>
|
</{{ tag }}>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -2,12 +2,12 @@
|
||||||
<div data-module="sidebar" class="docs-sidebar">
|
<div data-module="sidebar" class="docs-sidebar">
|
||||||
|
|
||||||
<div class="docs-sidebar__toggler">
|
<div class="docs-sidebar__toggler">
|
||||||
{{ svg('menu') }} Table of contents
|
{{ svg('menu') }} Содержание
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="docs-sidebar__content docs-sidebar__content--invisible">
|
<aside class="docs-sidebar__content docs-sidebar__content--invisible">
|
||||||
<span class="docs-sidebar__search-wrapper">
|
<span class="docs-sidebar__search-wrapper">
|
||||||
<input class="docs-sidebar__search" type="text" placeholder="Search" />
|
<input class="docs-sidebar__search" type="text" placeholder="Поиск по статьям" />
|
||||||
</span>
|
</span>
|
||||||
{% for firstLevelPage in menu %}
|
{% for firstLevelPage in menu %}
|
||||||
<section class="docs-sidebar__section" data-id="{{firstLevelPage._id}}">
|
<section class="docs-sidebar__section" data-id="{{firstLevelPage._id}}">
|
||||||
|
@ -43,17 +43,6 @@
|
||||||
</section>
|
</section>
|
||||||
{% endfor %}
|
{% 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>
|
</aside>
|
||||||
|
|
||||||
<div class="docs-sidebar__slider">
|
<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 %}
|
<figure class="block-image" style="width: {{ width }}; max-width: 100%; margin:
|
||||||
{% set classes = classes|merge(['block-image__content--bordered']) %}
|
{% if alignment == 'left' %}0 auto 0 0
|
||||||
{% endif %}
|
{% elseif alignment == 'right' %}0 0 0 auto
|
||||||
|
{% else %}0 auto
|
||||||
{% if withBackground %}
|
{% endif %};">
|
||||||
{% set classes = classes|merge(['block-image__content--with-background']) %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<figure class="block-image">
|
|
||||||
<div class="{{ classes.join(' ') }}">
|
<div class="{{ classes.join(' ') }}">
|
||||||
{% if file.mime and file.mime == 'video/mp4' %}
|
{% 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">
|
<source src="{{ file.url }}" type="video/mp4">
|
||||||
</video>
|
</video>
|
||||||
{% else %}
|
{% else %}
|
||||||
<img src="{{ file.url }}" alt="{{ caption ? caption | striptags : '' }}">
|
<img
|
||||||
|
src="{{ file.url }}"
|
||||||
|
alt="{{ caption ? caption | striptags : '' }}"
|
||||||
|
style="width: {{ width }};"
|
||||||
|
>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</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 %}
|
{% endif %}
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
{% if parentsChildrenOrdered is not empty %}
|
{% if parentsChildrenOrdered is not empty %}
|
||||||
<label for="parent">Parent Page</label>
|
<label for="parent">Родительская страница</label>
|
||||||
{% else %}
|
{% else %}
|
||||||
<label for="parent">New Page at the</label>
|
<label for="parent">Новая страница в</label>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<select id="parent" name="parent">
|
<select id="parent" name="parent">
|
||||||
<option value="0">Root</option>
|
<option value="0">Корень</option>
|
||||||
{% for _page in pagesAvailableGrouped %}
|
{% for _page in pagesAvailableGrouped %}
|
||||||
{% if toString(_page._id) != toString(currentPageId) %}
|
{% if toString(_page._id) != toString(currentPageId) %}
|
||||||
<option value="{{ toString(_page._id) }}" {{ page is not empty and toString(page._parent) == toString(_page._id) ? 'selected' : ''}}>
|
<option value="{{ toString(_page._id) }}" {{ page is not empty and toString(page._parent) == toString(_page._id) ? 'selected' : ''}}>
|
||||||
|
@ -41,7 +41,7 @@
|
||||||
</div>
|
</div>
|
||||||
{% if parentsChildrenOrdered is not empty %}
|
{% if parentsChildrenOrdered is not empty %}
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<label for="above">Put Above</label>
|
<label for="above">Поместить выше</label>
|
||||||
<select id="above" name="above">
|
<select id="above" name="above">
|
||||||
<option value="0">—</option>
|
<option value="0">—</option>
|
||||||
{% for _page in parentsChildrenOrdered %}
|
{% for _page in parentsChildrenOrdered %}
|
||||||
|
@ -52,8 +52,8 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if page is not empty %}
|
{% if page is not empty %}
|
||||||
<div class="uri-input-wrapper">
|
<div class="uri-input-wrapper">
|
||||||
<label for="uri-input">Alias</label>
|
<label for="uri-input">Алиас</label>
|
||||||
<input type="text" id="uri-input" class="uri-input" name="uri-input" placeholder="URI (Optional)" value="{{ page.uri }}">
|
<input type="text" id="uri-input" class="uri-input" name="uri-input" placeholder="URI (Опционально)" value="{{ page.uri }}">
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -63,9 +63,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="writing-buttons">
|
<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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -16,9 +16,9 @@
|
||||||
<div class="greeting-content">
|
<div class="greeting-content">
|
||||||
{{ svg('frog') }}
|
{{ svg('frog') }}
|
||||||
<p class="greeting-content__message">
|
<p class="greeting-content__message">
|
||||||
It’s time to create the first page!
|
Пора создать первую страницу!
|
||||||
</p>
|
</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>
|
</div>
|
||||||
{% if config.yandexMetrikaId is not empty %}
|
{% if config.yandexMetrikaId is not empty %}
|
||||||
<script type="text/javascript" >
|
<script type="text/javascript" >
|
||||||
|
@ -36,4 +36,3 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
<header class="page__header">
|
<header class="page__header">
|
||||||
<div class="page__header-nav">
|
<div class="page__header-nav">
|
||||||
<a href="/" class="page__header-nav-item">
|
<a href="/" class="page__header-nav-item">
|
||||||
Documentation
|
Документация
|
||||||
</a>
|
</a>
|
||||||
{{ svg('arrow-right') }}
|
{{ svg('arrow-right') }}
|
||||||
{% if page._parent %}
|
{% if page._parent %}
|
||||||
|
@ -20,10 +20,10 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<time class="page__header-time">
|
<time class="page__header-time">
|
||||||
Last edit {{ (page.body.time / 1000) | date("M d Y") }}
|
Изменена: {{ (page.body.time / 1000) | date("M d Y") }}
|
||||||
</time>
|
</time>
|
||||||
{% if isAuthorized == true %}
|
{% 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 %}
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
<h1 class="page__title">
|
<h1 class="page__title">
|
||||||
|
@ -36,7 +36,7 @@
|
||||||
{% for block in page.body.blocks %}
|
{% for block in page.body.blocks %}
|
||||||
{# Skip first header, because it is already showed as a Title #}
|
{# Skip first header, because it is already showed as a Title #}
|
||||||
{% if not (loop.first and block.type == 'header') %}
|
{% 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">
|
<div class="page__content-block">
|
||||||
{% include './blocks/' ~ block.type ~ '.twig' with block.data %}
|
{% include './blocks/' ~ block.type ~ '.twig' with block.data %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -47,4 +47,23 @@
|
||||||
{% include '../components/navigator.twig' with {previousPage: previousPage, nextPage: nextPage} %}
|
{% include '../components/navigator.twig' with {previousPage: previousPage, nextPage: nextPage} %}
|
||||||
</article>
|
</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 %}
|
{% 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 InlineCode from '@editorjs/inline-code';
|
||||||
import Marker from '@editorjs/marker';
|
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
|
* Class for working with Editor.js
|
||||||
*/
|
*/
|
||||||
export default class Editor {
|
export default class Editor {
|
||||||
|
@ -35,6 +38,10 @@ export default class Editor {
|
||||||
*/
|
*/
|
||||||
constructor(editorConfig = {}, options = {}) {
|
constructor(editorConfig = {}, options = {}) {
|
||||||
const defaultConfig = {
|
const defaultConfig = {
|
||||||
|
// Включаем поддержку отмены действий (CTRL+Z)
|
||||||
|
enableHistoryStack: true,
|
||||||
|
// Максимальное количество действий, которые можно отменить
|
||||||
|
maxHistoryLength: 30,
|
||||||
tools: {
|
tools: {
|
||||||
header: {
|
header: {
|
||||||
class: 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: {
|
image: {
|
||||||
class: Image,
|
class: Image,//CustomImageTool
|
||||||
inlineToolbar: true,
|
inlineToolbar: true,
|
||||||
config: {
|
config: {
|
||||||
types: 'image/*, video/mp4',
|
types: 'image/*, video/mp4',
|
||||||
|
|
|
@ -229,7 +229,7 @@ export default class TableOfContent {
|
||||||
this.nodes.wrapper = $.make('section', this.CSS.tocContainer);
|
this.nodes.wrapper = $.make('section', this.CSS.tocContainer);
|
||||||
|
|
||||||
const header = $.make('header', this.CSS.tocHeader, {
|
const header = $.make('header', this.CSS.tocHeader, {
|
||||||
textContent: 'On this page',
|
textContent: 'На этой странице',
|
||||||
});
|
});
|
||||||
|
|
||||||
this.nodes.wrapper.appendChild(header);
|
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-title--active,
|
||||||
&__section-list-item--active {
|
&__section-list-item--active {
|
||||||
background: linear-gradient(270deg, #129bff 0%, #8a53ff 100%);
|
background: linear-gradient(270deg, #ff8812 0%, #f50000 100%);
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
@media (--can-hover) {
|
@media (--can-hover) {
|
||||||
|
|
|
@ -66,6 +66,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ce-paragraph[style*="color"] {
|
||||||
|
padding: 2px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.ce-header {
|
.ce-header {
|
||||||
@apply --text-header;
|
@apply --text-header;
|
||||||
padding: 0 0 10px;
|
padding: 0 0 10px;
|
||||||
|
@ -118,3 +122,427 @@
|
||||||
.cdx-settings-button[data-tune="stretched"] {
|
.cdx-settings-button[data-tune="stretched"] {
|
||||||
display: none;
|
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;
|
text-decoration: none;
|
||||||
color: inherit
|
color: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue