From 13e4c04c3e0c6cd122d54a69237011c839f49504 Mon Sep 17 00:00:00 2001 From: popovskik Date: Mon, 27 Jul 2026 17:39:25 +0200 Subject: [PATCH] Improve article TOC, sticky header, and cross-language anchors - Pin the site header on scroll (position: absolute -> fixed) so it no longer disappears when reading. - Redesign the desktop table-of-contents sidebar: compact spacing and fix the first-item clipping at 100% zoom (drop fixed-height flex centering in the scroll container for a flex column with max-height). - Add the article title above the desktop TOC so readers always know which article they are in. - Make the article TOC a site-wide standard: main.js now auto-builds the sidebar + sticky mobile header from each page's own h1/h2 headings, so all languages (EN/SI/MK) get it with no per-page HTML. Existing hand-written EN labels are preserved. - Update the URL hash to the active section while scrolling (replaceState, no history spam, no jump). - Add transliteration for readable anchors in every language: Slovenian diacritics and Macedonian Cyrillic map to ASCII slugs. Co-Authored-By: Claude Opus 4.8 (1M context) --- css/layout/_header.css | 4 +- css/pages/_article.css | 38 +++++++-- main.js | 190 ++++++++++++++++++++++++++++++----------- 3 files changed, 175 insertions(+), 57 deletions(-) diff --git a/css/layout/_header.css b/css/layout/_header.css index b2c8837f..f68a25c0 100644 --- a/css/layout/_header.css +++ b/css/layout/_header.css @@ -15,7 +15,9 @@ /* 1. Glavni kontejner za glavo */ .dropdown-header-navigation { - position: absolute; + position: fixed; + top: 0; + left: 0; width: 100%; height: 80px; background: #FFFFFF; diff --git a/css/pages/_article.css b/css/pages/_article.css index 4f1dbdb9..6456da69 100644 --- a/css/pages/_article.css +++ b/css/pages/_article.css @@ -51,15 +51,36 @@ width: 288px; flex-shrink: 0; position: sticky; - top: 120px; - height: calc(100vh - 240px); + top: 112px; + /* max-height (not fixed height) so short lists size to content and never clip; + the list only scrolls when the viewport is genuinely too short. */ + max-height: calc(100vh - 140px); + display: flex; + flex-direction: column; +} + +/* Article title above the table of contents, so the reader always knows + which article they are reading. Injected by main.js from the page

. */ +.article-sidebar-title { + flex-shrink: 0; + margin: 0 0 16px 0; + padding: 0 0 16px 20px; + font-size: 16px; + font-weight: 600; + line-height: 24px; + color: #101828; + border-bottom: 1px solid #EAECF0; } .scrollspy-nav { - height: 100%; + /* Fill the remaining sidebar height and scroll internally when needed. + min-height:0 lets a flex child actually shrink so overflow works. */ + flex: 1 1 auto; + min-height: 0; overflow-y: auto; - display: flex; - align-items: center; + /* No flex centering here: centering inside a scroll container pushed the first + item out of reach at 100% zoom / short viewports. Content now flows from the top. */ + padding: 4px 0; } .scrollspy-nav ul { @@ -68,15 +89,16 @@ margin: 0; display: flex; flex-direction: column; - gap: 40px; + gap: 8px; border-left: 2px solid #EAECF0; } .scrollspy-nav li a { display: block; - padding: 12px 24px; + padding: 8px 20px; text-decoration: none; - font-size: 16px; + font-size: 14px; + line-height: 20px; font-weight: 500; color: #667085; border-left: 2px solid transparent; diff --git a/main.js b/main.js index fdcc0970..e90352f4 100644 --- a/main.js +++ b/main.js @@ -275,39 +275,134 @@ document.addEventListener('DOMContentLoaded', function() { try { const articlePage = document.querySelector('.article-page'); if (articlePage) { - const scrollspyNav = document.querySelector('.scrollspy-nav'); - const mobileHeader = document.querySelector('.mobile-article-header'); - const mobileTitle = document.getElementById('mobile-article-title'); - const mobileDropdown = document.querySelector('.mobile-scrollspy-dropdown'); - const mobileLinksContainer = document.getElementById('mobile-scrollspy-links'); - const progressBar = document.querySelector('.progress-bar'); - - const headings = Array.from(articlePage.querySelectorAll('.article-body h2')); - const desktopNavLinks = scrollspyNav ? Array.from(scrollspyNav.querySelectorAll('a')) : []; + const articleContainer = articlePage.querySelector('.article-container'); + const articleBody = articlePage.querySelector('.article-body'); + const headings = articleBody ? Array.from(articleBody.querySelectorAll('h2')) : []; + const articleTitleEl = articlePage.querySelector('.article-header h1'); + const articleTitle = articleTitleEl ? articleTitleEl.textContent.trim() + : (document.title || '').trim(); - // --- Dinamično ustvarjanje mobilnega spustnega seznama --- - if (headings.length > 0 && mobileLinksContainer) { + // Label for a heading in the table of contents. An optional + // data-nav-title attribute lets an article override the full

+ // text with a shorter label; otherwise we use the heading text. + const navLabel = (heading) => + (heading.getAttribute('data-nav-title') || heading.textContent).trim(); + + // --- 5a. Zagotovi, da ima vsak naslov enoličen id (za sidra) --- + // Transliterate then slugify so anchors are readable in every language: + // Slovenian diacritics (č/š/ž/…) and Macedonian Cyrillic map to ASCII. + // Anything still unmapped is dropped and falls back to section-N below. + const TRANSLIT = { + // Slovenian / Latin diacritics + 'č':'c','ć':'c','š':'s','ž':'z','đ':'dj', + 'á':'a','à':'a','â':'a','ä':'a','ã':'a','é':'e','è':'e','ê':'e','ë':'e', + 'í':'i','ì':'i','î':'i','ï':'i','ó':'o','ò':'o','ô':'o','ö':'o','õ':'o', + 'ú':'u','ù':'u','û':'u','ü':'u','ñ':'n','ç':'c','ß':'ss', + // Macedonian Cyrillic + 'а':'a','б':'b','в':'v','г':'g','д':'d','ѓ':'gj','е':'e','ж':'zh','з':'z', + 'ѕ':'dz','и':'i','ј':'j','к':'k','л':'l','љ':'lj','м':'m','н':'n','њ':'nj', + 'о':'o','п':'p','р':'r','с':'s','т':'t','ќ':'kj','у':'u','ф':'f','х':'h', + 'ц':'c','ч':'ch','џ':'dj','ш':'sh' + }; + const usedIds = new Set(); + const transliterate = (text) => + text.replace(/[^\x00-\x7F]/g, (ch) => (ch in TRANSLIT ? TRANSLIT[ch] : '')); + const slugify = (text) => transliterate(text.toLowerCase()) + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + headings.forEach((heading, i) => { + let id = heading.id || slugify(heading.textContent); + if (id.length < 2) id = 'section-' + (i + 1); + let unique = id, n = 2; + while (usedIds.has(unique)) { unique = id + '-' + (n++); } + usedIds.add(unique); + heading.id = unique; + }); + + // --- 5b. Zagotovi postavitveni ovoj in stransko vrstico (Desktop) --- + // Older article pages (SI/MK and some EN) ship without the TOC + // scaffolding, so we build it here from the page's own headings. + let layoutContainer = articlePage.querySelector('.article-layout-container'); + if (!layoutContainer && articleContainer) { + layoutContainer = document.createElement('div'); + layoutContainer.className = 'article-layout-container'; + articleContainer.parentNode.insertBefore(layoutContainer, articleContainer); + layoutContainer.appendChild(articleContainer); + } + + let sidebar = articlePage.querySelector('.article-sidebar'); + if (!sidebar && layoutContainer && headings.length > 0) { + sidebar = document.createElement('aside'); + sidebar.className = 'article-sidebar'; + sidebar.innerHTML = ''; + layoutContainer.insertBefore(sidebar, layoutContainer.firstChild); + } + + const scrollspyNav = sidebar ? sidebar.querySelector('.scrollspy-nav') : null; + const desktopUl = scrollspyNav ? scrollspyNav.querySelector('ul') : null; + + // --- 5c. Napolni namizni seznam, če je prazen (ohrani ročno napisanega) --- + if (desktopUl && desktopUl.children.length === 0 && headings.length > 0) { + headings.forEach(heading => { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.href = '#' + heading.id; + a.textContent = navLabel(heading); + li.appendChild(a); + desktopUl.appendChild(li); + }); + } + + // --- 5d. Naslov članka nad kazalom (Desktop) --- + if (sidebar && !sidebar.querySelector('.article-sidebar-title') && articleTitle) { + const titleEl = document.createElement('p'); + titleEl.className = 'article-sidebar-title'; + titleEl.textContent = articleTitle; + sidebar.insertBefore(titleEl, sidebar.firstChild); + } + + // --- 5e. Zagotovi lepljivo mobilno glavo z naslovom + progress bar --- + let mobileHeader = articlePage.querySelector('.mobile-article-header'); + if (!mobileHeader && headings.length > 0) { + mobileHeader = document.createElement('div'); + mobileHeader.className = 'mobile-article-header'; + mobileHeader.innerHTML = + '' + + '' + + '
' + + '
'; + articlePage.insertBefore(mobileHeader, articlePage.firstChild); + } + + const mobileTitle = document.getElementById('mobile-article-title'); + if (mobileTitle && !mobileTitle.textContent.trim()) { + mobileTitle.textContent = articleTitle; // known at load; scrollspy refines it + } + const mobileDropdown = articlePage.querySelector('.mobile-scrollspy-dropdown'); + const mobileLinksContainer = document.getElementById('mobile-scrollspy-links'); + const progressBar = articlePage.querySelector('.progress-bar'); + const desktopNavLinks = desktopUl ? Array.from(desktopUl.querySelectorAll('a')) : []; + + // --- 5f. Napolni mobilni spustni seznam --- + if (headings.length > 0 && mobileLinksContainer && mobileLinksContainer.children.length === 0) { headings.forEach(heading => { const listItem = document.createElement('li'); const link = document.createElement('a'); - link.href = `#${heading.id}`; - link.textContent = heading.textContent; + link.href = '#' + heading.id; + link.textContent = navLabel(heading); listItem.appendChild(link); mobileLinksContainer.appendChild(listItem); }); } - - // --- Logika za odpiranje/zapiranje mobilnega spustnega seznama --- + + // --- 5g. Odpiranje/zapiranje mobilnega spustnega seznama --- if (mobileHeader && mobileDropdown) { mobileHeader.addEventListener('click', (e) => { - // Preprečimo, da se meni zapre, če kliknemo na povezavo znotraj njega if (!e.target.closest('a')) { - mobileHeader.classList.toggle('open'); - mobileDropdown.classList.toggle('open'); + mobileHeader.classList.toggle('open'); + mobileDropdown.classList.toggle('open'); } }); - - // Dodamo dogodek za zapiranje menija ob kliku na povezavo mobileDropdown.querySelectorAll('a').forEach(link => { link.addEventListener('click', () => { mobileHeader.classList.remove('open'); @@ -316,56 +411,55 @@ document.addEventListener('DOMContentLoaded', function() { }); } - // --- Logika za Progress Bar --- + // --- 5h. Progress Bar --- function updateProgressBar() { if (!progressBar) return; const scrollableHeight = document.documentElement.scrollHeight - window.innerHeight; const scrollTop = window.scrollY; - - if (scrollableHeight > 0) { - const progress = (scrollTop / scrollableHeight) * 100; - progressBar.style.width = `${progress}%`; - } else { - progressBar.style.width = '0%'; - } + progressBar.style.width = scrollableHeight > 0 + ? `${(scrollTop / scrollableHeight) * 100}%` + : '0%'; } - // --- Logika za Scrollspy --- + // --- 5i. Scrollspy (posodobi navigacijo, naslov in URL sidro) --- if (headings.length > 0) { + let activeId = null; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { - const id = entry.target.getAttribute('id'); - const desktopNavLink = desktopNavLinks.find(link => link.getAttribute('href') === `#${id}`); - const mobileNavLink = mobileLinksContainer ? mobileLinksContainer.querySelector(`a[href="#${id}"]`) : null; + if (!entry.isIntersecting) return; + const id = entry.target.id; + if (id === activeId) return; + activeId = id; - if (entry.isIntersecting) { - // Posodobi namizno navigacijo - if (desktopNavLink) { - desktopNavLinks.forEach(link => link.classList.remove('active')); - desktopNavLink.classList.add('active'); - } - // Posodobi mobilni naslov in aktivno povezavo v spustnem seznamu - if (mobileTitle) { - mobileTitle.textContent = entry.target.textContent; - } + // Namizna navigacija + const desktopNavLink = desktopNavLinks.find(link => link.getAttribute('href') === `#${id}`); + if (desktopNavLink) { + desktopNavLinks.forEach(link => link.classList.remove('active')); + desktopNavLink.classList.add('active'); + } + // Mobilni naslov + aktivna povezava + if (mobileTitle) mobileTitle.textContent = navLabel(entry.target); + if (mobileLinksContainer) { + const mobileNavLink = mobileLinksContainer.querySelector(`a[href="#${id}"]`); if (mobileNavLink) { mobileLinksContainer.querySelectorAll('a').forEach(link => link.classList.remove('active')); mobileNavLink.classList.add('active'); } } + // Posodobi URL sidro brez skoka in brez polnjenja zgodovine + if (window.history && history.replaceState) { + history.replaceState(null, '', '#' + id); + } }); }, { - rootMargin: "-100px 0px -50% 0px" // Sproži, ko je naslov v zgornjem delu zaslona + rootMargin: "-100px 0px -55% 0px" // Sproži, ko je naslov v zgornjem delu zaslona }); - headings.forEach(heading => { - observer.observe(heading); - }); + headings.forEach(heading => observer.observe(heading)); } - // Dodaj poslušalca dogodkov za vrstico napredka window.addEventListener('scroll', updateProgressBar); - updateProgressBar(); // Klic ob nalaganju strani + updateProgressBar(); } } catch (error) { console.error('Error in Article Scrollspy/Progress Bar setup:', error); -- 2.43.0