From 22b732633cc5e6f97975d608a1c18c7489230125 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Thu, 17 Sep 2026 20:16:35 +0300 Subject: [PATCH] Learn: section actions on the title row, scrollable tables, copy code --- .../ru/ysu/lms/markdown/MarkdownRenderer.kt | 7 +- web/src/main/resources/static/css/app.css | 38 +++++++-- .../main/resources/static/js/content-sort.js | 13 ++- .../main/resources/static/js/md-content.js | 80 +++++++++++++++++++ .../resources/templates/fragments/base.html | 7 ++ .../resources/templates/learn/chapter.html | 57 ++++++------- .../main/resources/templates/learn/index.html | 54 ++++++------- 7 files changed, 189 insertions(+), 67 deletions(-) create mode 100644 web/src/main/resources/static/js/md-content.js diff --git a/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/MarkdownRenderer.kt b/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/MarkdownRenderer.kt index 2fdbf4c..04b5b6d 100644 --- a/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/MarkdownRenderer.kt +++ b/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/MarkdownRenderer.kt @@ -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("", "") private fun renderRaw(markdown: String, depth: Int): String { val out = StringBuilder() diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index 03bf3a2..374fe66 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -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; } /* ---- Матрица группы и карточки защиты ---- */ diff --git a/web/src/main/resources/static/js/content-sort.js b/web/src/main/resources/static/js/content-sort.js index 793a372..24f6a3c 100644 --- a/web/src/main/resources/static/js/content-sort.js +++ b/web/src/main/resources/static/js/content-sort.js @@ -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) { diff --git a/web/src/main/resources/static/js/md-content.js b/web/src/main/resources/static/js/md-content.js new file mode 100644 index 0000000..f24b7e7 --- /dev/null +++ b/web/src/main/resources/static/js/md-content.js @@ -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(); + } +})(); diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index c924004..aadebfa 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -12,6 +12,7 @@ + diff --git a/web/src/main/resources/templates/learn/chapter.html b/web/src/main/resources/templates/learn/chapter.html index bce40ee..b934fa2 100644 --- a/web/src/main/resources/templates/learn/chapter.html +++ b/web/src/main/resources/templates/learn/chapter.html @@ -47,42 +47,43 @@
-
- +
+
⠿ - + Раздел скрыто -
-
-
-
- -
- - - - - -
- -
- - - -
-
+
+ +
+ + + + + +
+ +
+ + + +
+
+
  • -
+
diff --git a/web/src/main/resources/templates/learn/index.html b/web/src/main/resources/templates/learn/index.html index c6a52c9..373bccc 100644 --- a/web/src/main/resources/templates/learn/index.html +++ b/web/src/main/resources/templates/learn/index.html @@ -57,41 +57,41 @@

Разделов пока нет

-
- +
+
⠿ - + Раздел скрыто -
-
-
-
-
- - - - -
- - -
- - -
-
+
+
+ + + + +
+ + +
+ + +
+
+
- +