Липкое оглавление статьи для uCoz с короткими цифровыми якорями

Юрий Герук 2025-12-20 67
Липкое оглавление статьи для uCoz с короткими цифровыми якорями

Введение

Длинные статьи на сайте неудобно читать без навигации. Оглавление решает это сразу: показывает структуру, дает быстрые переходы, подсвечивает текущий раздел при прокрутке.

Ниже готовое решение для uCoz, которое автоматически строит оглавление по заголовкам статьи и назначает короткие якоря вида toc_1, toc_2, toc_3 вместо длинных ссылок из текста.

Что делает скрипт

  • Ищет заголовки в тексте статьи.
  • Поддерживает h2, h3, h4, h5.
  • Если у заголовка нет id, назначает короткий цифровой якорь toc_1, toc_2 и так далее.
  • Строит оглавление в указанном контейнере.
  • Делает плавную прокрутку по клику.
  • Подсвечивает активный пункт при прокрутке.
  • Позволяет свернуть и развернуть оглавление.
  • Аккуратно выглядит на мобильных.

Важно про eMessage и где именно скрипт ищет заголовки

Да. Скрипт ищет заголовки не по всей странице, а внутри блока с текстом материала. На uCoz чаще всего текст материала находится в контейнере .eMessage, поэтому он используется первым.

Если у вас в шаблоне текст выводится в другом контейнере, нужно указать его селектор в настройках скрипта. Это одна строка в конфиге, покажу ниже.

Скрипт использует список селекторов и берет первый найденный:

  • .eMessage это самый частый вариант на uCoz.
  • .entry-content, .post-content, article, main и другие добавлены как запасные.

Установка на uCoz

Шаг 1. Добавьте контейнер для оглавления

В шаблоне страницы материала нужного модуля вставьте контейнер там, где должно появиться оглавление. Обычно его ставят перед выводом текста статьи.

<div id="tocMount"></div>

Шаг 2. Добавьте стили

Вставьте стили в шаблон. Можно в общий CSS, можно перед </body>.

<style>
 :root{
 --toc-bg: rgba(0,0,0,.04);
 --toc-border: rgba(0,0,0,.12);
 --toc-text: inherit;
 --toc-muted: rgba(0,0,0,.65);
 --toc-accent: #0d6efd;
 --toc-radius: 14px;
 }

 @media (prefers-color-scheme: dark){
 :root{
 --toc-bg: rgba(255,255,255,.06);
 --toc-border: rgba(255,255,255,.14);
 --toc-muted: rgba(255,255,255,.7);
 }
 }

 .u-toc{
 margin: 16px 0 18px 0;
 padding: 14px 14px 12px 14px;
 border: 1px solid var(--toc-border);
 border-radius: var(--toc-radius);
 background: var(--toc-bg);
 color: var(--toc-text);
 }

 .u-toc__top{
 display: flex;
 align-items: center;
 gap: 10px;
 justify-content: space-between;
 }

 .u-toc__title{
 font-weight: 700;
 font-size: 16px;
 line-height: 1.2;
 margin: 0;
 display: flex;
 align-items: center;
 gap: 10px;
 min-width: 0;
 }

 .u-toc__badge{
 font-weight: 600;
 font-size: 12px;
 line-height: 1;
 padding: 4px 8px;
 border-radius: 999px;
 border: 1px solid var(--toc-border);
 color: var(--toc-muted);
 white-space: nowrap;
 flex: 0 0 auto;
 }

 .u-toc__btn{
 appearance: none;
 border: 1px solid var(--toc-border);
 background: transparent;
 color: inherit;
 border-radius: 10px;
 padding: 8px 10px;
 font-size: 14px;
 line-height: 1;
 cursor: pointer;
 user-select: none;
 transition: transform .05s ease, background-color .15s ease;
 flex: 0 0 auto;
 }

 .u-toc__btn:active{
 transform: translateY(1px);
 }

 .u-toc__btn:hover{
 background: rgba(0,0,0,.05);
 }

 @media (prefers-color-scheme: dark){
 .u-toc__btn:hover{
 background: rgba(255,255,255,.08);
 }
 }

 .u-toc__list{
 margin: 12px 0 0 0;
 padding: 0;
 list-style: none;
 }

 .u-toc__item{
 margin: 6px 0;
 padding: 0;
 }

 .u-toc__link{
 display: inline-flex;
 gap: 8px;
 align-items: baseline;
 text-decoration: none;
 color: inherit;
 padding: 6px 8px;
 border-radius: 10px;
 transition: background-color .15s ease, color .15s ease;
 }

 .u-toc__link:hover{
 background: rgba(0,0,0,.05);
 }

 @media (prefers-color-scheme: dark){
 .u-toc__link:hover{
 background: rgba(255,255,255,.08);
 }
 }

 .u-toc__dot{
 width: 8px;
 height: 8px;
 border-radius: 999px;
 border: 2px solid var(--toc-border);
 flex: 0 0 auto;
 position: relative;
 top: 1px;
 }

 .u-toc__link.is-active{
 color: var(--toc-accent);
 background: rgba(13,110,253,.08);
 }

 .u-toc__link.is-active .u-toc__dot{
 border-color: var(--toc-accent);
 background: var(--toc-accent);
 }

 .u-toc__sub{
 margin-left: 18px;
 }

 .u-toc.is-collapsed .u-toc__list{
 display: none;
 }

 .u-toc__hint{
 margin: 10px 0 0 0;
 font-size: 12px;
 color: var(--toc-muted);
 }

 .u-toc__hint code{
 font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
 font-size: 12px;
 }

 @media (max-width: 575.98px){
 .u-toc__top{
 flex-wrap: wrap;
 align-items: flex-start;
 gap: 10px;
 }

 .u-toc__title{
 width: 100%;
 flex-wrap: wrap;
 gap: 8px;
 }

 .u-toc__btn{
 width: 100%;
 display: block;
 text-align: center;
 }
 }
</style>

Шаг 3. Добавьте скрипт

Вставьте код перед </body> в шаблоне страницы материала.

В конфиге есть два места, которые обычно правят под шаблон:

  • headings если нужно добавить или убрать уровни заголовков.
  • contentSelectors если текст статьи у вас не в .eMessage.
<script>
(function () {
 "use strict";

 var CFG = {
 mountId: "tocMount",

 // Какие заголовки собирать в оглавление.
 // Можно оставить "h2, h3" если нужны только два уровня.
 headings: "h2, h3, h4, h5",

 // Минимум заголовков, чтобы оглавление показывалось.
 minHeadings: 2,

 // Префикс для коротких якорей.
 idPrefix: "toc_",

 // Отступ при прокрутке к заголовку, если есть фиксированная шапка.
 activeOffsetPx: 110,

 // Плавная прокрутка.
 smoothScroll: true,

 // Где искать заголовки.
 // Скрипт берет первый найденный контейнер.
 contentSelectors: [
 ".eMessage",
 ".entry-content",
 ".post-content",
 ".u-entry__text",
 "article",
 "main",
 ".content",
 "body"
 ],

 // Запоминаем свернуто или нет.
 storageKey: "ucoz_toc_collapsed_v6"
 };

 function qs(sel, root) { return (root || document).querySelector(sel); }
 function qsa(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }

 function escapeHtml(s){
 return String(s).replace(/[&<>"']/g, function(ch){
 return ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" })[ch];
 });
 }

 function pickContentRoot(){
 for (var i = 0; i < CFG.contentSelectors.length; i++) {
 var el = qs(CFG.contentSelectors[i]);
 if (el) return el;
 }
 return document.body;
 }

 function ensureUniqueId(base){
 var id = base;
 var n = 2;
 while (document.getElementById(id)) {
 id = base + n;
 n++;
 }
 return id;
 }

 function build(){
 var mount = document.getElementById(CFG.mountId);
 if (!mount) return;

 var root = pickContentRoot();

 var hs = qsa(CFG.headings, root).filter(function(h){
 var txt = (h.textContent || "").trim();
 return txt.length > 0;
 });

 if (hs.length < CFG.minHeadings) return;

 var items = [];
 var seq = 1;

 hs.forEach(function(h){
 var text = (h.textContent || "").trim();
 var level = (h.tagName || "").toLowerCase();

 var id = h.getAttribute("id");
 if (!id) {
 id = ensureUniqueId(CFG.idPrefix + String(seq));
 h.setAttribute("id", id);
 seq++;
 }

 items.push({ id: id, level: level, text: text, el: h });
 });

 if (items.length < CFG.minHeadings) return;

 var isCollapsed = false;
 try { isCollapsed = localStorage.getItem(CFG.storageKey) === "1"; } catch(e) {}

 var html = "";
 html += '<nav class="u-toc' + (isCollapsed ? ' is-collapsed' : '') + '" aria-label="Оглавление">';
 html += '<div class="u-toc__top">';
 html += '<p class="u-toc__title">Оглавление <span class="u-toc__badge">' + items.length + '</span></p>';
 html += '<button type="button" class="u-toc__btn" data-toc-toggle>' + (isCollapsed ? 'Показать' : 'Свернуть') + '</button>';
 html += '</div>';
 html += '<ul class="u-toc__list">';

 items.forEach(function(it){
 var subClass = (it.level === "h3" || it.level === "h4" || it.level === "h5") ? " u-toc__sub" : "";
 html += '<li class="u-toc__item' + subClass + '">';
 html += '<a class="u-toc__link" href="#' + encodeURIComponent(it.id) + '" data-toc-link="' + escapeHtml(it.id) + '">';
 html += '<span class="u-toc__dot" aria-hidden="true"></span>';
 html += '<span class="u-toc__text">' + escapeHtml(it.text) + '</span>';
 html += '</a>';
 html += '</li>';
 });

 html += '</ul>';
 html += '<p class="u-toc__hint">Якоря короткие. Пример: <code>#' + escapeHtml(CFG.idPrefix) + '1</code>.</p>';
 html += '</nav>';

 mount.innerHTML = html;

 var toc = mount.querySelector(".u-toc");
 var btn = mount.querySelector("[data-toc-toggle]");
 var links = qsa("[data-toc-link]", mount);

 if (btn) {
 btn.addEventListener("click", function(){
 var collapsed = toc.classList.toggle("is-collapsed");
 btn.textContent = collapsed ? "Показать" : "Свернуть";
 try { localStorage.setItem(CFG.storageKey, collapsed ? "1" : "0"); } catch(e) {}
 }, { passive: true });
 }

 links.forEach(function(a){
 a.addEventListener("click", function(ev){
 var id = a.getAttribute("data-toc-link");
 var target = document.getElementById(id);
 if (!target) return;

 ev.preventDefault();

 var top = target.getBoundingClientRect().top + window.pageYOffset - CFG.activeOffsetPx;
 if (CFG.smoothScroll && "scrollBehavior" in document.documentElement.style) {
 window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
 } else {
 window.scrollTo(0, Math.max(0, top));
 }

 history.replaceState(null, "", "#" + encodeURIComponent(id));
 }, { passive: false });
 });

 var currentId = null;

 function setActive(id){
 if (currentId === id) return;
 currentId = id;
 links.forEach(function(a){
 a.classList.toggle("is-active", a.getAttribute("data-toc-link") === id);
 });
 }

 function onScroll(){
 var y = window.pageYOffset || document.documentElement.scrollTop || 0;
 var best = null;

 for (var i = 0; i < items.length; i++) {
 var el = items[i].el;
 var top = el.getBoundingClientRect().top + window.pageYOffset;
 if (top - CFG.activeOffsetPx <= y) best = items[i].id;
 }

 setActive(best || items[0].id);
 }

 onScroll();
 window.addEventListener("scroll", onScroll, { passive: true });
 window.addEventListener("resize", onScroll, { passive: true });
 }

 if (document.readyState === "loading") {
 document.addEventListener("DOMContentLoaded", build);
 } else {
 build();
 }
})();
</script>

Настройка под ваш шаблон

Если заголовки находятся не в .eMessage, найдите контейнер, внутри которого выводится текст статьи, и добавьте его в начало списка contentSelectors.

Примеры:

  • Если текст в <div class="my-article">, добавьте .my-article.
  • Если текст в <div id="content">, добавьте #content.

Если хотите собирать только h2 и h3, измените строку:

  • Было: headings: "h2, h3, h4, h5"
  • Нужно: headings: "h2, h3"

Как писать статьи, чтобы оглавление работало правильно

  • Используйте настоящие заголовки h2, h3, h4, h5.
  • Не заменяйте заголовки жирным текстом и переносами строк.
  • Если у вас фиксированная шапка, увеличьте activeOffsetPx, чтобы прокрутка не прятала заголовок под шапкой.

Заключение

Оглавление повышает удобство чтения длинных материалов, делает структуру статьи понятной и добавляет нормальную навигацию, как в документации. При этом якоря остаются короткими и стабильными, независимо от того, как меняется текст заголовков.

Оцените полезность материала!

Лицензия: CC BY-SA 4.0

Автор: Юрий Герук

Похожие материалы:

Комментарии