Learn: inline markdown editor with formatting toolbar; drop edit modals
This commit is contained in:
@@ -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; }
|
||||
|
||||
130
web/src/main/resources/static/js/mdeditor.js
Normal file
130
web/src/main/resources/static/js/mdeditor.js
Normal file
@@ -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();
|
||||
});
|
||||
})();
|
||||
21
web/src/main/resources/templates/fragments/md-toolbar.html
Normal file
21
web/src/main/resources/templates/fragments/md-toolbar.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<!-- Панель форматирования markdown. target — CSS-селектор textarea -->
|
||||
<div th:fragment="toolbar(target)" class="md-toolbar" th:attr="data-md-target=${target}">
|
||||
<button type="button" class="md-btn" data-md="bold" title="Полужирный: **текст**"><b>Полужирный</b></button>
|
||||
<button type="button" class="md-btn" data-md="italic" title="Курсив: *текст*"><i>Курсив</i></button>
|
||||
<button type="button" class="md-btn" data-md="h2" title="Заголовок раздела">Заголовок</button>
|
||||
<button type="button" class="md-btn" data-md="ul" title="Маркированный список">Список</button>
|
||||
<button type="button" class="md-btn" data-md="ol" title="Нумерованный список">Нумерация</button>
|
||||
<button type="button" class="md-btn" data-md="quote" title="Цитата">Цитата</button>
|
||||
<button type="button" class="md-btn" data-md="link" title="Ссылка">Ссылка</button>
|
||||
<button type="button" class="md-btn" data-md="code" title="Код в строке">Код</button>
|
||||
<button type="button" class="md-btn" data-md="codeblock" title="Блок кода на C">Блок кода</button>
|
||||
<button type="button" class="md-btn" data-md="table" title="Таблица">Таблица</button>
|
||||
<button type="button" class="md-btn" data-md="figure" title="Картинка с подписью (файл в assets/)">Картинка</button>
|
||||
<button type="button" class="md-btn" data-md="video" title="Видео (файл в assets/)">Видео</button>
|
||||
<button type="button" class="md-btn" data-md="spoiler" title="Спойлер: текст скрыт, открывается по клику">Спойлер</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -46,7 +46,7 @@
|
||||
</h1>
|
||||
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="flex flex-wrap gap-2 mb-4">
|
||||
<button class="btn btn-primary btn-sm" type="button" onclick="openModal('modal-edit-article')">Править</button>
|
||||
<button class="btn btn-primary btn-sm" type="button" onclick="startArticleEdit()">Править</button>
|
||||
<form class="inline-form" th:action="@{/admin/content/articles/toggle-published}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="id" th:value="${article.id}">
|
||||
@@ -80,7 +80,30 @@
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
|
||||
<div id="article-view" class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
|
||||
|
||||
<form id="article-edit" class="card card-body form-stack" hidden
|
||||
th:if="${currentUser != null and currentUser.admin}"
|
||||
th:action="@{/admin/content/articles/{id}/save(id=${article.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<div><label class="label" for="article-title">Заголовок</label>
|
||||
<input class="input" id="article-title" name="title" th:value="${article.title}" required></div>
|
||||
<div>
|
||||
<label class="label" for="article-body">Текст статьи (markdown)</label>
|
||||
<div th:replace="~{fragments/md-toolbar :: toolbar('#article-body')}"></div>
|
||||
<textarea class="input md-editor-body" id="article-body" name="bodyMd" rows="24"
|
||||
th:text="${article.bodyMd}"></textarea>
|
||||
<p class="hint">
|
||||
Кнопки вставляют разметку в текст. Картинки и видео берутся из папки <code class="code">assets/</code>
|
||||
архива материалов (имена файлов — латиницей). Заголовок статьи — отдельное поле выше.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
<button class="btn btn-secondary" type="button" onclick="cancelArticleEdit()">Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<nav class="article-nav mt-6" th:if="${prevArticle != null or nextArticle != null}">
|
||||
<a th:if="${prevArticle != null}" class="btn btn-secondary"
|
||||
@@ -97,34 +120,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<dialog id="modal-edit-article" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Правка статьи</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-edit-article')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/content/articles/{id}/save(id=${article.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<div><label class="label" for="artTitle">Заголовок</label>
|
||||
<input class="input" id="artTitle" name="title" th:value="${article.title}" required></div>
|
||||
<div><label class="label" for="artBody">Текст (markdown)</label>
|
||||
<textarea class="input" id="artBody" name="bodyMd" rows="18" th:text="${article.bodyMd}"></textarea></div>
|
||||
<p class="hint">
|
||||
Картинка с подписью: <code class="code">:::figure assets/имя.svg | Рис. 1. Подпись</code>.
|
||||
Видео: <code class="code">:::video assets/имя.mp4 | Подпись</code>.
|
||||
Решение под спойлером: <code class="code">:::spoiler Решение … :::</code>.
|
||||
</p>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script th:if="${currentUser != null and currentUser.admin}">
|
||||
if (window.location.search.indexOf('edit=1') >= 0) {
|
||||
document.addEventListener('DOMContentLoaded', function () { openModal('modal-edit-article'); });
|
||||
}
|
||||
</script>
|
||||
<script src="/js/mdeditor.js" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -116,8 +116,8 @@
|
||||
th:classappend="${article.published} ? 'btn-secondary' : 'btn-primary'"
|
||||
th:text="${article.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
|
||||
</form>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-edit-article-${article.id}|" onclick="openModalData(this)">Править</button>
|
||||
<a class="btn btn-secondary btn-sm"
|
||||
th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug}, edit=1)}">Править</a>
|
||||
<form class="inline-form" th:action="@{/admin/content/articles/{id}/delete(id=${article.id})}" method="post"
|
||||
onsubmit="return confirm('Удалить статью?')">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
@@ -244,31 +244,14 @@
|
||||
<div><label class="label" for="naTitle">Заголовок статьи</label>
|
||||
<input class="input" id="naTitle" name="title" required></div>
|
||||
<div><label class="label" for="naBody">Текст (markdown)</label>
|
||||
<textarea class="input" id="naBody" name="bodyMd" rows="10"></textarea></div>
|
||||
<div th:replace="~{fragments/md-toolbar :: toolbar('#naBody')}"></div>
|
||||
<textarea class="input md-editor-body" id="naBody" name="bodyMd" rows="18"></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Создать</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<th:block th:if="${currentUser != null and currentUser.admin}" th:each="a : ${articles}">
|
||||
<dialog th:id="|modal-edit-article-${a.id}|" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Правка статьи</h2>
|
||||
<button class="modal-close" type="button"
|
||||
th:data-modal="|modal-edit-article-${a.id}|" onclick="closeModalData(this)">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/content/articles/{id}/save(id=${a.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<div><label class="label">Заголовок</label>
|
||||
<input class="input" name="title" th:value="${a.title}" required></div>
|
||||
<div><label class="label">Текст (markdown)</label>
|
||||
<textarea class="input" name="bodyMd" rows="14" th:text="${a.bodyMd}"></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</th:block>
|
||||
<script src="/js/mdeditor.js" defer></script>
|
||||
<script src="/js/content-sort.js" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -122,7 +122,8 @@
|
||||
<div><label class="label" for="naTitle">Заголовок</label>
|
||||
<input class="input" id="naTitle" name="title" required></div>
|
||||
<div><label class="label" for="naBody">Текст (markdown)</label>
|
||||
<textarea class="input" id="naBody" name="bodyMd" rows="14"></textarea></div>
|
||||
<div th:replace="~{fragments/md-toolbar :: toolbar('#naBody')}"></div>
|
||||
<textarea class="input md-editor-body" id="naBody" name="bodyMd" rows="18"></textarea></div>
|
||||
<p class="hint">
|
||||
Картинка: <code class="code">:::figure assets/имя.svg | Рис. 1. Подпись</code>;
|
||||
видео: <code class="code">:::video assets/имя.mp4 | Подпись</code>;
|
||||
|
||||
Reference in New Issue
Block a user