From 7a341fb8719139ae8e9aaa2f5626c39e389b5ce5 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Thu, 17 Sep 2026 18:31:43 +0300 Subject: [PATCH] Learn: inline markdown editor with formatting toolbar; drop edit modals --- docs/06-authoring.md | 8 +- web/src/main/resources/static/css/app.css | 21 +++ web/src/main/resources/static/js/mdeditor.js | 130 ++++++++++++++++++ .../templates/fragments/md-toolbar.html | 21 +++ .../resources/templates/learn/article.html | 55 ++++---- .../main/resources/templates/learn/index.html | 27 +--- .../resources/templates/learn/section.html | 3 +- 7 files changed, 210 insertions(+), 55 deletions(-) create mode 100644 web/src/main/resources/static/js/mdeditor.js create mode 100644 web/src/main/resources/templates/fragments/md-toolbar.html diff --git a/docs/06-authoring.md b/docs/06-authoring.md index 375bb09..be0c3c5 100644 --- a/docs/06-authoring.md +++ b/docs/06-authoring.md @@ -89,9 +89,11 @@ printf("Ответ: 42\n"); Администратору доступны действия не только на главной `/learn`, но и на каждой странице: -* **статья** — «Править» (модальное окно с markdown), «Скрыть/Опубликовать», «Удалить», - а также навигация **← предыдущая / следующая →** (работает и между разделами) и - **боковое содержание** со всеми главами, разделами и статьями; +* **статья** — «Править» (правка прямо на странице: текст статьи открывается в редакторе + с панелью форматирования — жирный, курсив, заголовки, списки, ссылки, код, таблицы, + картинки, видео, спойлер), «Скрыть/Опубликовать», «Удалить», а также навигация + **← предыдущая / следующая →** (работает и между разделами) и **боковое содержание** + со всеми главами, разделами и статьями; * **раздел** — «+ Статья», «Переименовать раздел», «Скрыть/Опубликовать», «Перенести статьи и удалить», «Удалить раздел»; у каждой статьи в списке — «Править» (открывает статью с готовым окном правки), скрытие и удаление; diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index 3fcbbea..faa3367 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -385,3 +385,24 @@ th.th-filterable { padding-bottom: 0.35rem; } .learn-main { order: 1; } .learn-side { position: static; max-height: none; order: 2; } } + +/* Панель форматирования markdown и встроенный редактор */ +.md-toolbar { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.4rem; } +.md-btn { + font: inherit; font-size: 0.78rem; line-height: 1.4; cursor: pointer; + padding: 0.2rem 0.5rem; border: 1px solid var(--c-border); border-radius: 0.35rem; + background: var(--c-surface); color: var(--c-text); +} +.md-btn:hover { background: var(--c-bg); } +.md-btn:active { transform: translateY(1px); } +.md-editor-body { + width: 100%; min-height: 20rem; font-family: "DejaVu Sans Mono", Consolas, monospace; + font-size: 0.9rem; line-height: 1.5; white-space: pre; overflow-wrap: normal; +} +.md-figure { margin: 1rem 0; text-align: center; } +.md-figure img { width: 100%; max-width: 100%; height: auto; } +.md-figure figcaption, .md-video-figure figcaption { + margin-top: 0.4rem; font-size: 0.875rem; color: var(--c-text-muted); +} +.md-video-figure { margin: 1rem 0; text-align: center; } +.md-video { width: 100%; max-width: 100%; border-radius: 0.5rem; background: #000; } diff --git a/web/src/main/resources/static/js/mdeditor.js b/web/src/main/resources/static/js/mdeditor.js new file mode 100644 index 0000000..27cb6f6 --- /dev/null +++ b/web/src/main/resources/static/js/mdeditor.js @@ -0,0 +1,130 @@ +/* Редактор markdown: панель кнопок применяется к textarea по data-md-target. + Плюс переключение статьи в режим правки прямо на странице (без модального окна). */ +(function () { + var dirty = false; + + function target(toolbar) { + return document.querySelector(toolbar.getAttribute('data-md-target')); + } + + function replaceSelection(area, before, after, placeholder) { + var start = area.selectionStart, end = area.selectionEnd; + var selected = area.value.slice(start, end) || placeholder || ''; + var text = before + selected + after; + area.setRangeText(text, start, end, 'end'); + if (!area.selectionEnd || selected === placeholder) { + // оставляем курсор внутри вставленного фрагмента + area.selectionStart = start + before.length; + area.selectionEnd = start + before.length + selected.length; + } + area.focus(); + area.dispatchEvent(new Event('input', { bubbles: true })); + } + + function prefixLines(area, prefix, numbered) { + var start = area.selectionStart, end = area.selectionEnd; + var value = area.value; + var lineStart = value.lastIndexOf('\n', start - 1) + 1; + var lineEnd = value.indexOf('\n', end); + if (lineEnd === -1) lineEnd = value.length; + var block = value.slice(lineStart, lineEnd); + var lines = block.split('\n'); + var changed = lines.map(function (line, index) { + if (line.trim() === '') return line; + return (numbered ? (index + 1) + '. ' : prefix) + line; + }).join('\n'); + area.setRangeText(changed, lineStart, lineEnd, 'end'); + area.focus(); + area.dispatchEvent(new Event('input', { bubbles: true })); + } + + function insertBlock(area, text, cursorOffset) { + var start = area.selectionStart; + var value = area.value; + var atLineStart = start === 0 || value[start - 1] === '\n'; + var prefix = atLineStart ? '' : '\n'; + var insert = prefix + text + '\n'; + area.setRangeText(insert, start, area.selectionEnd, 'end'); + var pos = area.selectionStart - 1 - (text.length - (cursorOffset || 0)); + area.selectionStart = area.selectionEnd = start + prefix.length + (cursorOffset || 0); + area.focus(); + area.dispatchEvent(new Event('input', { bubbles: true })); + } + + function apply(action, area) { + switch (action) { + case 'bold': replaceSelection(area, '**', '**', 'текст'); break; + case 'italic': replaceSelection(area, '*', '*', 'текст'); break; + case 'code': replaceSelection(area, '`', '`', 'код'); break; + case 'h2': prefixLines(area, '## '); break; + case 'ul': prefixLines(area, '- '); break; + case 'ol': prefixLines(area, '', true); break; + case 'quote': prefixLines(area, '> '); break; + case 'link': replaceSelection(area, '[', '](https://)', 'ссылка'); break; + case 'codeblock': + replaceSelection(area, '```c\n', '\n```', 'int main(void)\n{\n return 0;\n}'); + break; + case 'table': + insertBlock(area, '| Столбец | Столбец |\n|---|---|\n| значение | значение |', 2); + break; + case 'figure': + replaceSelection(area, ':::figure assets/', ' | Рис. 1. Подпись', 'имя.svg'); + break; + case 'video': + replaceSelection(area, ':::video assets/', ' | Подпись к видео', 'имя.mp4'); + break; + case 'spoiler': + replaceSelection(area, ':::spoiler Решение\n', '\n:::', 'решение'); + break; + } + } + + document.addEventListener('click', function (event) { + var button = event.target.closest('.md-btn'); + if (!button) return; + var toolbar = button.closest('.md-toolbar'); + if (!toolbar) return; + var area = target(toolbar); + if (!area) return; + event.preventDefault(); + apply(button.getAttribute('data-md'), area); + }); + + /* --- режим правки статьи на странице --- */ + window.startArticleEdit = function () { + var view = document.getElementById('article-view'); + var form = document.getElementById('article-edit'); + if (!view || !form) return; + view.hidden = true; + form.hidden = false; + dirty = false; + var area = document.getElementById('article-body'); + if (area) area.focus(); + }; + + window.cancelArticleEdit = function () { + if (dirty && !confirm('Выйти из редактирования без сохранения?')) return; + var view = document.getElementById('article-view'); + var form = document.getElementById('article-edit'); + if (!view || !form) return; + form.hidden = true; + view.hidden = false; + dirty = false; + }; + + document.addEventListener('input', function (event) { + if (event.target && event.target.id === 'article-body') dirty = true; + }); + + window.addEventListener('beforeunload', function (event) { + var form = document.getElementById('article-edit'); + if (dirty && form && !form.hidden) { + event.preventDefault(); + event.returnValue = ''; + } + }); + + document.addEventListener('DOMContentLoaded', function () { + if (window.location.search.indexOf('edit=1') >= 0) window.startArticleEdit(); + }); +})(); diff --git a/web/src/main/resources/templates/fragments/md-toolbar.html b/web/src/main/resources/templates/fragments/md-toolbar.html new file mode 100644 index 0000000..4c11ffc --- /dev/null +++ b/web/src/main/resources/templates/fragments/md-toolbar.html @@ -0,0 +1,21 @@ + + + + +
+ + + + + + + + + + + + + +
+ + diff --git a/web/src/main/resources/templates/learn/article.html b/web/src/main/resources/templates/learn/article.html index 9095da5..4cf2d84 100644 --- a/web/src/main/resources/templates/learn/article.html +++ b/web/src/main/resources/templates/learn/article.html @@ -46,7 +46,7 @@
- +
@@ -80,7 +80,30 @@ -
+
+ + + + +
+
+
+ +
+ +

+ Кнопки вставляют разметку в текст. Картинки и видео берутся из папки assets/ + архива материалов (имена файлов — латиницей). Заголовок статьи — отдельное поле выше. +

+
+
+ + +
+
diff --git a/web/src/main/resources/templates/learn/index.html b/web/src/main/resources/templates/learn/index.html index 6f7d11f..9f03be5 100644 --- a/web/src/main/resources/templates/learn/index.html +++ b/web/src/main/resources/templates/learn/index.html @@ -116,8 +116,8 @@ th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'" th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть - + Править
@@ -244,31 +244,14 @@
-
+
+
- - - - - - + diff --git a/web/src/main/resources/templates/learn/section.html b/web/src/main/resources/templates/learn/section.html index 86a36f8..8610caf 100644 --- a/web/src/main/resources/templates/learn/section.html +++ b/web/src/main/resources/templates/learn/section.html @@ -122,7 +122,8 @@
-
+
+

Картинка: :::figure assets/имя.svg | Рис. 1. Подпись; видео: :::video assets/имя.mp4 | Подпись;