Forms: return to previous scroll position after submit (no highlight/flicker); themed scrollbars everywhere

This commit is contained in:
2026-10-08 00:54:51 +03:00
parent 6788ff0607
commit 01237c6b0c
12 changed files with 68 additions and 45 deletions

View File

@@ -403,24 +403,14 @@ class AdminLabController(
redirect: RedirectAttributes,
): String =
try {
val detail =
api.saveVariant(
token(session),
labId,
form.variantId,
VariantSavePayload(form.labLevelId, form.number, form.title, form.statementMd),
)
api.saveVariant(
token(session),
labId,
form.variantId,
VariantSavePayload(form.labLevelId, form.number, form.title, form.statementMd),
)
redirect.addFlashAttribute("notice", "Вариант сохранён")
// вернуться к отредактированному/созданному варианту на странице лабы
val anchor =
form.variantId
?.takeIf { it.isNotBlank() }
?: detail.levels
.firstOrNull { it.id == form.labLevelId }
?.variants
?.firstOrNull { it.number == form.number.trim() }
?.id
if (anchor.isNullOrBlank()) "redirect:/admin/labs/$labId" else "redirect:/admin/labs/$labId#variant-$anchor"
"redirect:/admin/labs/$labId"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/labs/$labId"

View File

@@ -89,7 +89,6 @@ a:not(.btn):hover { color: var(--c-accent-hover); }
.user { display: flex; align-items: center; gap: 0.75rem; font-size: 0.84375rem; margin-left: auto; }
.nav-user { font-weight: 500; color: var(--c-text); }
.score-big { display: inline-block; margin-left: 0.5rem; font-size: 1.75rem; font-weight: 700; line-height: 1; color: var(--c-tone-pos); font-variant-numeric: tabular-nums; vertical-align: middle; }
.variant-block:target { outline: 2px solid var(--c-accent); outline-offset: 4px; border-radius: var(--radius); }
.variant-name { font-weight: 600; }
.variant-name b { font-variant-numeric: tabular-nums; }
p { margin-top: 0; }
@@ -179,8 +178,8 @@ code { --bs-code-color: var(--c-text); color: var(--c-text); }
a { --bs-link-color: var(--c-accent); --bs-link-hover-color: var(--c-accent-hover); text-decoration: none; }
/* ---- Полосы прокрутки в стиле приложения ---- */
html { scrollbar-width: thin; scrollbar-color: var(--c-border-strong) var(--c-bg); }
/* ---- Полосы прокрутки в стиле приложения (везде: страница, модалки, таблицы, pre) ---- */
* { scrollbar-width: thin; scrollbar-color: var(--c-border-strong) var(--c-bg); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--c-bg); }
::-webkit-scrollbar-thumb { background: var(--c-border-strong); border: 2px solid var(--c-bg); border-radius: 8px; }

View File

@@ -42,6 +42,8 @@
if (document.querySelector('dialog[open]')) {
if (root.classList.contains('modal-open')) return;
modalScrollY = window.scrollY || window.pageYOffset || 0;
// запоминаем позицию до блокировки — её использует ui-state.js при сохранении формы
window.__pageScrollY = modalScrollY;
var sw = window.innerWidth - root.clientWidth;
root.style.setProperty('--modal-scrollbar', (sw > 0 ? sw : 0) + 'px');
root.style.setProperty('--modal-top', (-modalScrollY) + 'px');
@@ -51,6 +53,9 @@
root.style.removeProperty('--modal-scrollbar');
root.style.removeProperty('--modal-top');
window.scrollTo(0, modalScrollY);
// повторно после пересчёта разметки, иначе прокрутка может «зажаться» в 0
requestAnimationFrame(function () { window.scrollTo(0, modalScrollY); });
delete window.__pageScrollY;
}
}
document.addEventListener('toggle', function (e) {

View File

@@ -1,8 +1,10 @@
/* Состояние страницы при перезагрузке: открытые <details> и позиция прокрутки.
Нужно, чтобы после сохранения формы или перерисовки страница возвращалась
туда же, а не «схлопывала» разделы и не сбрасывала прокрутку в начало. */
/* Состояние страницы при перезагрузке/возврате после отправки формы:
позиция прокрутки и раскрытые <details>. Возврат — мгновенный, без анимации.
Позиция фиксируется «на лету», пока страница не заблокирована модалкой
(при открытой модалке body зафиксирован и window.scrollY = 0 — его нельзя терять). */
(function () {
var KEY = 'lms-ui-state';
var lastY = window.scrollY || window.pageYOffset || 0;
function keyOf(element, index) {
return element.getAttribute('data-chapter-id')
@@ -11,8 +13,28 @@
|| ('details-' + index);
}
// ключ страницы — только путь: после поисковых GET-форм добавляется ?q=,
// но это та же страница, и позицию нужно восстановить
function currentUrl() {
return location.pathname + location.search;
return location.pathname;
}
function locked() {
return document.documentElement.classList.contains('modal-open');
}
// запоминаем позицию до блокировки скролла модалкой
window.addEventListener('scroll', function () {
if (!locked()) lastY = window.scrollY || window.pageYOffset || 0;
}, { passive: true });
function scrollY() {
// позиция, надёжная при любом состоянии: window.scrollY может быть 0
// из-за блокировки модалкой или зажатия прокрутки при снятии блокировки,
// поэтому берём максимум из текущей, последней до блокировки и сохранённой app.js
var y = window.scrollY || window.pageYOffset || 0;
if (typeof window.__pageScrollY === 'number') y = Math.max(y, window.__pageScrollY);
return Math.max(y, lastY);
}
function save() {
@@ -23,7 +45,7 @@
try {
sessionStorage.setItem(KEY, JSON.stringify({
url: currentUrl(),
y: window.scrollY || window.pageYOffset || 0,
y: scrollY(),
open: open
}));
} catch (e) { /* приватный режим — просто пропускаем */ }
@@ -49,11 +71,18 @@
if (typeof state.y === 'number' && state.y > 0) {
var y = state.y;
// мгновенно, без плавной прокрутки; повторно — после полной загрузки,
// если разметка «доразрослась» и позиция сбилась
window.scrollTo(0, y);
requestAnimationFrame(function () { window.scrollTo(0, y); });
setTimeout(function () { window.scrollTo(0, y); }, 80);
window.addEventListener('load', function () { window.scrollTo(0, y); });
}
}
// сохраняем перед любой отправкой формы (в т.ч. из модалок) и при уходе со страницы
document.addEventListener('submit', save, true);
window.addEventListener('beforeunload', save);
// возврат по Back/Forward из кэша браузера
window.addEventListener('pageshow', function (event) { if (event.persisted) restore(); });
document.addEventListener('DOMContentLoaded', restore);
})();

View File

@@ -342,7 +342,7 @@
<div class="dlg-body" id="assign-modal-body"></div>
</dialog>
<script th:src="@{/js/admin-group.js(v='20')}" defer></script>
<script th:src="@{/js/admin-group.js(v='21')}" defer></script>
</div>
</body>
</html>

View File

@@ -196,7 +196,7 @@
</dialog>
</th:block>
</div>
<script th:src="@{/js/admin-lab.js(v='20')}" defer></script>
<script th:src="@{/js/admin-lab.js(v='21')}" defer></script>
</div>
</body>
</html>

View File

@@ -17,18 +17,18 @@
<!-- v=4: сброс кэша стилей после редизайна; при изменении css поднимать версию -->
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
<link rel="stylesheet" th:href="@{/css/bootstrap-icons.min.css}">
<link rel="stylesheet" th:href="@{/css/app.css(v='20')}">
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='20')}">
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='20')}" disabled>
<script th:src="@{/js/highlight.min.js(v='20')}" defer></script>
<script th:src="@{/js/table-scroll.js(v='20')}" defer></script>
<script th:src="@{/js/tablekit.js(v='20')}" defer></script>
<link rel="stylesheet" th:href="@{/css/app.css(v='21')}">
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='21')}">
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='21')}" disabled>
<script th:src="@{/js/highlight.min.js(v='21')}" defer></script>
<script th:src="@{/js/table-scroll.js(v='21')}" defer></script>
<script th:src="@{/js/tablekit.js(v='21')}" defer></script>
<script th:src="@{/js/bootstrap.bundle.min.js}" defer></script>
<script th:src="@{/js/theme-init.js(v='20')}"></script>
<script th:src="@{/js/app.js(v='20')}" defer></script>
<script th:src="@{/js/ui-state.js(v='20')}" defer></script>
<script th:src="@{/js/md-content.js(v='20')}" defer></script>
<script th:src="@{/js/submit-guard.js(v='20')}" defer></script>
<script th:src="@{/js/theme-init.js(v='21')}"></script>
<script th:src="@{/js/app.js(v='24')}" defer></script>
<script th:src="@{/js/ui-state.js(v='24')}" defer></script>
<script th:src="@{/js/md-content.js(v='21')}" defer></script>
<script th:src="@{/js/submit-guard.js(v='21')}" defer></script>
</head>
<body>
<div class="page d-flex flex-column min-vh-100">

View File

@@ -124,7 +124,7 @@
</div>
<script th:src="@{/js/mdeditor.js(v='20')}" defer></script>
<script th:src="@{/js/mdeditor.js(v='21')}" defer></script>
</div>
</body>
</html>

View File

@@ -183,7 +183,7 @@
</div>
</dialog>
<script th:src="@{/js/content-sort.js(v='20')}" defer></script>
<script th:src="@{/js/content-sort.js(v='21')}" defer></script>
</div>
</body>
</html>

View File

@@ -236,8 +236,8 @@
</div>
</dialog>
<script th:src="@{/js/mdeditor.js(v='20')}" defer></script>
<script th:src="@{/js/content-sort.js(v='20')}" defer></script>
<script th:src="@{/js/mdeditor.js(v='21')}" defer></script>
<script th:src="@{/js/content-sort.js(v='21')}" defer></script>
</div>
</body>
</html>

View File

@@ -143,7 +143,7 @@
</div>
</dialog>
<script th:src="@{/js/content-sort.js(v='20')}" defer></script>
<script th:src="@{/js/content-sort.js(v='21')}" defer></script>
</div>
</body>
</html>

View File

@@ -107,7 +107,7 @@
<p class="text-sm text-muted">Ожидайте преподавателя с проверкой.</p>
</div>
</div>
<script th:src="@{/js/defense-timer.js(v='20')}" defer></script>
<script th:src="@{/js/defense-timer.js(v='21')}" defer></script>
</div>
</body>
</html>