Learn: section actions on the title row, scrollable tables, copy code
This commit is contained in:
@@ -51,7 +51,12 @@ class MarkdownRenderer(
|
||||
private val renderer: HtmlRenderer = HtmlRenderer.builder(options).build()
|
||||
private val sanitizer: PolicyFactory = buildSanitizer()
|
||||
|
||||
fun render(markdown: String): String = sanitizer.sanitize(renderRaw(markdown, depth = 0))
|
||||
fun render(markdown: String): String = wrapTables(sanitizer.sanitize(renderRaw(markdown, depth = 0)))
|
||||
|
||||
/** Оборачивает таблицы в прокручиваемый контейнер, чтобы на мобильных они не выезжали. */
|
||||
private fun wrapTables(html: String): String =
|
||||
html.replace("<table", "<div class=\"table-scroll\"><table")
|
||||
.replace("</table>", "</table></div>")
|
||||
|
||||
private fun renderRaw(markdown: String, depth: Int): String {
|
||||
val out = StringBuilder()
|
||||
|
||||
@@ -218,7 +218,21 @@ dialog.modal::backdrop { background: rgba(0,0,0,0.55); }
|
||||
.md-body details.spoiler { border: 1px solid var(--c-border); border-radius: 0.5rem; padding: 0.5rem 0.75rem; margin: 0.5rem 0; }
|
||||
.md-body details.spoiler summary { cursor: pointer; font-weight: 500; color: var(--c-text-link); }
|
||||
.md-body img { max-width: 100%; }
|
||||
.md-body table { margin: 0.5rem 0; }
|
||||
.md-body table { margin: 0; }
|
||||
/* Таблицы в статьях прокручиваются по горизонтали, как блоки кода */
|
||||
.table-scroll { max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 0.5rem 0; }
|
||||
/* Блок кода: кнопка копирования в правом верхнем углу */
|
||||
.md-code-wrap { position: relative; margin: 0.5rem 0; }
|
||||
.md-code-wrap pre { margin: 0; }
|
||||
.md-copy {
|
||||
position: absolute; top: 0.4rem; right: 0.4rem;
|
||||
font: inherit; font-size: 0.72rem; line-height: 1;
|
||||
padding: 0.25rem 0.5rem; cursor: pointer;
|
||||
color: var(--c-text-muted); background: var(--c-surface);
|
||||
border: 1px solid var(--c-border); border-radius: 0.35rem; opacity: 0.85;
|
||||
}
|
||||
.md-copy:hover { color: var(--c-text); opacity: 1; }
|
||||
.md-copy.copied { color: var(--c-text-link); }
|
||||
|
||||
/* Empty state */
|
||||
.empty { text-align: center; color: var(--c-text-muted); padding: 1.5rem; }
|
||||
@@ -253,12 +267,22 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos
|
||||
.sort-list { list-style: none; margin: 0; padding: 0; }
|
||||
.sort-list > li { display: flex; align-items: center; gap: 0.5rem; min-width: 0; padding: 0.15rem 0; }
|
||||
.chapter-card { padding: 0.75rem 1rem; }
|
||||
details.sub-acc { margin-top: 0.25rem; border-top: 1px solid var(--c-border); }
|
||||
[data-section-list] > details.sub-acc:first-of-type { border-top: none; }
|
||||
details.sub-acc > summary.sub-head { list-style: none; cursor: pointer; padding: 0.4rem 0; }
|
||||
details.sub-acc > summary::-webkit-details-marker { display: none; }
|
||||
.sub-arrow { flex: 0 0 auto; width: 8px; height: 8px; border-right: 2px solid var(--c-text-muted); border-bottom: 2px solid var(--c-text-muted); transform: rotate(45deg); transition: transform 0.2s; }
|
||||
details.sub-acc[open] > summary .sub-arrow { transform: rotate(-135deg); }
|
||||
.sub-acc { margin-top: 0.25rem; border-top: 1px solid var(--c-border); }
|
||||
[data-section-list] > .sub-acc:first-of-type { border-top: none; }
|
||||
.sub-acc > .sub-head { padding: 0.4rem 0; }
|
||||
.sub-acc.collapsed > .section-body { display: none; }
|
||||
.sub-toggle {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 auto; width: 1rem; height: 1rem; padding: 0; border: none;
|
||||
background: none; cursor: pointer; color: var(--c-text-muted);
|
||||
}
|
||||
.sub-toggle:hover { color: var(--c-text); }
|
||||
.sub-arrow {
|
||||
flex: 0 0 auto; width: 8px; height: 8px;
|
||||
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
|
||||
transform: rotate(-135deg); transition: transform 0.2s;
|
||||
}
|
||||
.sub-acc.collapsed .sub-arrow { transform: rotate(45deg); }
|
||||
.section-body { padding-bottom: 0.4rem; }
|
||||
|
||||
/* ---- Матрица группы и карточки защиты ---- */
|
||||
|
||||
@@ -123,17 +123,22 @@
|
||||
autoExpand(hoverHost(target));
|
||||
}
|
||||
|
||||
/* Пока тянем, раскрываем закрытую главу/раздел под курсором, чтобы внутрь можно было бросить. */
|
||||
/* Пока тянем, раскрываем свёрнутый раздел под курсором, чтобы внутрь можно было бросить. */
|
||||
function hoverHost(target) {
|
||||
return target.closest('details[data-chapter-id], details[data-section-id]');
|
||||
return target.closest('.sub-acc[data-section-id]');
|
||||
}
|
||||
|
||||
function autoExpand(host) {
|
||||
if (!host || host.open || host === state.element) return;
|
||||
if (!host || host === state.element) return;
|
||||
if (!host.classList.contains('collapsed')) return;
|
||||
if (hoverTarget === host) return;
|
||||
hoverTarget = host;
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = setTimeout(function () { host.open = true; }, 600);
|
||||
hoverTimer = setTimeout(function () {
|
||||
host.classList.remove('collapsed');
|
||||
var toggle = host.querySelector('.sub-toggle');
|
||||
if (toggle) toggle.setAttribute('aria-expanded', 'true');
|
||||
}, 600);
|
||||
}
|
||||
|
||||
function onDrop(event) {
|
||||
|
||||
80
web/src/main/resources/static/js/md-content.js
Normal file
80
web/src/main/resources/static/js/md-content.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/* Блоки кода в материалах: кнопка «Копировать» в углу и копирование по клику на блок. */
|
||||
(function () {
|
||||
function fallbackCopy(text) {
|
||||
var area = document.createElement('textarea');
|
||||
area.value = text;
|
||||
area.setAttribute('readonly', '');
|
||||
area.style.position = 'fixed';
|
||||
area.style.top = '-1000px';
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
try { document.execCommand('copy'); } catch (e) { /* игнорируем */ }
|
||||
document.body.removeChild(area);
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
|
||||
}
|
||||
fallbackCopy(text);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function codeText(pre) {
|
||||
var code = pre.querySelector('code');
|
||||
if (code) return code.innerText.replace(/\n+$/, '');
|
||||
var clone = pre.cloneNode(true);
|
||||
clone.querySelectorAll('.md-copy').forEach(function (btn) { btn.remove(); });
|
||||
return clone.textContent.replace(/\n+$/, '');
|
||||
}
|
||||
|
||||
function flash(btn) {
|
||||
if (btn.dataset.busy) return;
|
||||
btn.dataset.busy = '1';
|
||||
var original = btn.textContent;
|
||||
btn.textContent = 'Скопировано';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(function () {
|
||||
btn.textContent = original;
|
||||
btn.classList.remove('copied');
|
||||
delete btn.dataset.busy;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function enhance(pre) {
|
||||
if (pre.dataset.copyReady) return;
|
||||
pre.dataset.copyReady = '1';
|
||||
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'md-code-wrap';
|
||||
pre.parentNode.insertBefore(wrap, pre);
|
||||
wrap.appendChild(pre);
|
||||
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'md-copy';
|
||||
btn.textContent = 'Копировать';
|
||||
btn.title = 'Скопировать код';
|
||||
btn.setAttribute('aria-label', 'Скопировать код');
|
||||
btn.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copyText(codeText(pre)).then(function () { flash(btn); });
|
||||
});
|
||||
wrap.appendChild(btn);
|
||||
|
||||
pre.addEventListener('click', function () {
|
||||
copyText(codeText(pre)).then(function () { flash(btn); });
|
||||
});
|
||||
}
|
||||
|
||||
function ready() {
|
||||
document.querySelectorAll('.md-body pre').forEach(enhance);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', ready);
|
||||
} else {
|
||||
ready();
|
||||
}
|
||||
})();
|
||||
@@ -12,6 +12,7 @@
|
||||
<script th:src="@{/js/highlight.min.js}" defer></script>
|
||||
<script th:src="@{/js/tablekit.js}" defer></script>
|
||||
<script th:src="@{/js/ui-state.js}" defer></script>
|
||||
<script th:src="@{/js/md-content.js}" defer></script>
|
||||
<script>
|
||||
(function(){
|
||||
var t = localStorage.getItem('lms-theme') || 'light';
|
||||
@@ -34,6 +35,12 @@
|
||||
function closeModal(id){ var d=document.getElementById(id); if(d) d.close(); }
|
||||
function openModalData(btn){ openModal(btn.dataset.modal); }
|
||||
function closeModalData(btn){ closeModal(btn.dataset.modal); }
|
||||
function toggleSection(btn){
|
||||
var acc = btn.closest('.sub-acc');
|
||||
if (!acc) return;
|
||||
var collapsed = acc.classList.toggle('collapsed');
|
||||
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -47,42 +47,43 @@
|
||||
</div>
|
||||
|
||||
<div class="card card-body chapter-card mb-4" data-section-list>
|
||||
<details th:each="section : ${chapter.sections}" class="sub-acc" th:attr="data-section-id=${section.id}">
|
||||
<summary class="sub-head sort-row">
|
||||
<div th:each="section : ${chapter.sections}" class="sub-acc" th:attr="data-section-id=${section.id}">
|
||||
<div class="sub-head sort-row">
|
||||
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
|
||||
th:attr="data-drag-kind='section',data-drag-id=${section.id}"
|
||||
title="Перетащите мышью: изменить порядок разделов">⠿</span>
|
||||
<span class="sub-arrow" aria-hidden="true"></span>
|
||||
<button type="button" class="sub-toggle" aria-expanded="true" onclick="toggleSection(this)"
|
||||
title="Свернуть или развернуть раздел">
|
||||
<span class="sub-arrow" aria-hidden="true"></span>
|
||||
</button>
|
||||
<a class="group-title sort-title m-0"
|
||||
th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}"
|
||||
th:text="${section.title}">Раздел</a>
|
||||
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span>
|
||||
</summary>
|
||||
<div class="section-body">
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="sort-row">
|
||||
<div class="sort-actions">
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
|
||||
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="id" th:value="${section.id}">
|
||||
<input type="hidden" name="published" th:value="${!section.published}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<button class="btn btn-sm" type="submit"
|
||||
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
|
||||
th:text="${section.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
|
||||
</form>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)">Перенести статьи и удалить</button>
|
||||
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
|
||||
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
|
||||
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="id" th:value="${section.id}">
|
||||
<input type="hidden" name="published" th:value="${!section.published}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<button class="btn btn-sm" type="submit"
|
||||
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
|
||||
th:text="${section.published} ? 'Скрыть' : 'Опубликовать'">Скрыть</button>
|
||||
</form>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)">Перенести статьи и удалить</button>
|
||||
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
|
||||
onsubmit="return confirm('Удалить раздел вместе со статьями?')">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="back" th:value="${back}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-body">
|
||||
<ul class="sort-list text-sm" data-article-list>
|
||||
<li th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}">
|
||||
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
|
||||
@@ -149,7 +150,7 @@
|
||||
</div>
|
||||
</dialog>
|
||||
</th:block>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="modal-rename-chapter" class="modal">
|
||||
|
||||
@@ -57,41 +57,41 @@
|
||||
<div data-section-list>
|
||||
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted mt-2 mb-0">Разделов пока нет</p>
|
||||
|
||||
<details th:each="section : ${chapter.sections}" class="sub-acc"
|
||||
th:attr="data-section-id=${section.id}">
|
||||
<summary class="sub-head sort-row">
|
||||
<div th:each="section : ${chapter.sections}" class="sub-acc" th:attr="data-section-id=${section.id}">
|
||||
<div class="sub-head sort-row">
|
||||
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
|
||||
th:attr="data-drag-kind='section',data-drag-id=${section.id}"
|
||||
title="Перетащите мышью: изменить порядок или перенести раздел">⠿</span>
|
||||
<span class="sub-arrow" aria-hidden="true"></span>
|
||||
<button type="button" class="sub-toggle" aria-expanded="true" onclick="toggleSection(this)"
|
||||
title="Свернуть или развернуть раздел">
|
||||
<span class="sub-arrow" aria-hidden="true"></span>
|
||||
</button>
|
||||
<a class="group-title sort-title m-0"
|
||||
th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}"
|
||||
th:text="${section.title}">Раздел</a>
|
||||
<span th:if="${!section.published}" class="badge badge-warn">скрыто</span>
|
||||
</summary>
|
||||
<div class="section-body">
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="sort-row">
|
||||
<div class="sort-actions">
|
||||
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="id" th:value="${section.id}">
|
||||
<input type="hidden" name="published" th:value="${!section.published}">
|
||||
<button class="btn btn-sm" type="submit"
|
||||
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
|
||||
th:text="${section.published} ? 'Скрыть раздел' : 'Опубликовать раздел'">Скрыть раздел</button>
|
||||
</form>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)">Перенести статьи</button>
|
||||
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
|
||||
onsubmit="return confirm('Удалить раздел со всеми статьями?')">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Удалить раздел</button>
|
||||
</form>
|
||||
</div>
|
||||
<div th:if="${currentUser != null and currentUser.admin}" class="sort-actions">
|
||||
<form class="inline-form" th:action="@{/admin/content/sections/toggle-published}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="id" th:value="${section.id}">
|
||||
<input type="hidden" name="published" th:value="${!section.published}">
|
||||
<button class="btn btn-sm" type="submit"
|
||||
th:classappend="${section.published} ? 'btn-secondary' : 'btn-primary'"
|
||||
th:text="${section.published} ? 'Скрыть раздел' : 'Опубликовать раздел'">Скрыть раздел</button>
|
||||
</form>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-rename-section-${section.id}|" onclick="openModalData(this)">Переименовать</button>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:data-modal="|modal-move-${section.id}|" onclick="openModalData(this)">Перенести статьи</button>
|
||||
<form class="inline-form" th:action="@{/admin/content/sections/{id}/delete(id=${section.id})}" method="post"
|
||||
onsubmit="return confirm('Удалить раздел со всеми статьями?')">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Удалить раздел</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-body">
|
||||
<ul class="sort-list text-sm" data-article-list>
|
||||
<li th:each="article : ${section.articles}" th:attr="data-article-id=${article.id}">
|
||||
<span th:if="${currentUser != null and currentUser.admin}" class="drag-handle" draggable="true"
|
||||
@@ -159,7 +159,7 @@
|
||||
</div>
|
||||
</dialog>
|
||||
</th:block>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<th:block th:if="${currentUser != null and currentUser.admin}">
|
||||
|
||||
Reference in New Issue
Block a user