pridaná generovaná navigácia z H1–H3 s unikátnymi ID,

pridaný responzívny panel a ukladanie stavu podľa URL,
pridané verzovanie CSS a JS pomocou MD5 hashov,
aktualizovaná dokumentácia
This commit is contained in:
2026-10-26 19:08:34 +01:00
parent 7b39f26c99
commit 715a0e354b
5 changed files with 325 additions and 15 deletions
+156 -1
View File
@@ -9,6 +9,7 @@
--quote-bg: #f4f8fa;
--code-bg: #1f2328;
--code-text: #f4f6f8;
--toc-width: 19rem;
}
*,
@@ -201,10 +202,164 @@ body {
}
.markdown-body :target {
scroll-margin-top: 1rem;
scroll-margin-top: 4.5rem;
animation: mark-target 1.2s ease;
}
/* Navigation lives outside the rendered Markdown. */
.md2html-document {
transition: margin-left 200ms ease;
}
.markdown-body :is(h1, h2, h3) {
scroll-margin-top: 4.5rem;
}
html:has(.md2html-toc-ready) {
scroll-behavior: smooth;
}
.md2html-toc-toggle,
.md2html-toc-close {
display: inline-grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
padding: 0;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--accent);
background: var(--paper-bg);
font: 1.25rem/1 sans-serif;
cursor: pointer;
}
.md2html-toc-toggle {
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 30;
box-shadow: 0 2px 6px #40312118;
}
.md2html-toc-toggle[hidden],
.md2html-toc[hidden],
.md2html-toc-backdrop[hidden] {
display: none;
}
.md2html-toc-toggle[aria-expanded="true"] {
color: var(--paper-bg);
background: var(--accent);
}
.md2html-toc :is(a, button):focus-visible,
.md2html-toc-toggle:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 2px;
}
.md2html-toc {
position: fixed;
z-index: 20;
top: 4rem;
bottom: 1rem;
left: 0.75rem;
width: var(--toc-width);
max-width: calc(100% - 4.5rem);
max-height: calc(100vh - 5rem);
max-height: calc(100dvh - 5rem);
overflow-y: auto;
overscroll-behavior: contain;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--paper-bg);
box-shadow: 0 8px 24px #40312120;
font: 0.95rem/1.5 "Segoe UI", sans-serif;
overflow-wrap: anywhere;
visibility: hidden;
transform: translateX(calc(-100% - 1rem));
transition: transform 200ms ease, visibility 200ms;
}
.md2html-toc-open .md2html-toc {
visibility: visible;
transform: translateX(0);
}
.md2html-toc-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.md2html-toc-close {
flex-shrink: 0;
}
.md2html-toc-list {
list-style: none;
margin: 0;
padding: 0;
}
.md2html-toc-list a {
display: block;
padding: 0.4rem 0.25rem;
color: var(--accent);
text-decoration: none;
border-radius: 4px;
}
.md2html-toc-list a:hover {
background: var(--accent-soft);
text-decoration: underline;
}
.md2html-toc-level-2 { padding-left: 1rem; }
.md2html-toc-level-3 { padding-left: 2rem; }
.md2html-toc-backdrop { display: none; }
@media (min-width: 761px) {
.md2html-toc-open .md2html-document {
margin-left: calc(var(--toc-width) + 1.5rem);
}
}
@media (max-width: 760px) {
.md2html-toc-ready .markdown-body {
margin-top: 4.25rem;
}
.md2html-toc-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 10;
background: #24201c55;
visibility: hidden;
opacity: 0;
transition: opacity 200ms ease, visibility 200ms;
}
.md2html-toc-open .md2html-toc-backdrop {
visibility: visible;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
html:has(.md2html-toc-ready) { scroll-behavior: auto; }
.md2html-document,
.md2html-toc,
.md2html-toc-backdrop { transition: none; }
.markdown-body :target { animation: none; }
}
@keyframes mark-target {
0% {
background: #fff3c9;
+11 -1
View File
@@ -14,7 +14,17 @@ $parser = new Parsedown();
echo '<!doctype html>';
echo '<meta charset="utf-8">';
echo '<link rel="stylesheet" href="//localhost/md2html/src/markdown.css">';
echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
echo '<link rel="stylesheet" href="//localhost/md2html/src/markdown.css?h=' . md5_file(__DIR__ . '/markdown.css') . '">';
echo '<script src="//localhost/md2html/src/toc.js?h=' . md5_file(__DIR__ . '/toc.js') . '" defer></script>';
echo '<button class="md2html-toc-toggle" type="button" aria-expanded="false" aria-label="Zobraziť obsah dokumentu" hidden><span aria-hidden="true">☰</span></button>';
echo '<div class="md2html-toc-backdrop" aria-hidden="true" hidden></div>';
echo '<nav class="md2html-toc" aria-label="Obsah dokumentu" hidden>';
echo '<div class="md2html-toc-header"><strong>Obsah dokumentu</strong><button class="md2html-toc-close" type="button" aria-label="Zatvoriť obsah dokumentu">×</button></div>';
echo '<ol class="md2html-toc-list"></ol>';
echo '</nav>';
echo '<div class="md2html-document">';
echo '<div class="markdown-body">';
echo $parser->text($md);
echo '</div>';
echo '</div>';
+94
View File
@@ -0,0 +1,94 @@
(() => {
'use strict';
const content = document.querySelector('.markdown-body');
const panel = document.querySelector('.md2html-toc');
const toggle = document.querySelector('.md2html-toc-toggle');
const close = document.querySelector('.md2html-toc-close');
const backdrop = document.querySelector('.md2html-toc-backdrop');
const list = document.querySelector('.md2html-toc-list');
if (!content || !panel || !toggle || !close || !backdrop || !list) return;
// Reserve all existing anchors, including anchors outside headings.
const usedIds = new Set(Array.from(document.querySelectorAll('[id]'), node => node.id));
function uniqueId(base) {
let id = base;
let suffix = 2;
while (usedIds.has(id)) id = `${base}-${suffix++}`;
usedIds.add(id);
return id;
}
panel.id = uniqueId('md2html-toc-panel');
toggle.setAttribute('aria-controls', panel.id);
const headings = content.querySelectorAll('h1, h2, h3');
const seenHeadingIds = new Set();
for (const heading of headings) {
const label = heading.textContent.trim() || 'Bez názvu';
if (!heading.id || seenHeadingIds.has(heading.id) || document.getElementById(heading.id) !== heading) {
const slug = label.normalize('NFC').toLowerCase()
.replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-+|-+$/g, '') || 'nadpis';
heading.id = uniqueId(slug);
}
seenHeadingIds.add(heading.id);
const item = document.createElement('li');
item.className = `md2html-toc-level-${heading.tagName.slice(1)}`;
const link = document.createElement('a');
link.setAttribute('href', `#${encodeURIComponent(heading.id)}`);
link.textContent = label;
link.addEventListener('click', () => {
if (window.matchMedia('(max-width: 760px)').matches) setOpen(false);
if (!heading.hasAttribute('tabindex')) heading.setAttribute('tabindex', '-1');
heading.focus({ preventScroll: true });
// Native anchor navigation updates the fragment and supports Back/Forward.
});
item.append(link);
list.append(item);
}
if (!headings.length) {
const message = document.createElement('p');
message.textContent = 'Obsah dokumentu nie je dostupný.';
list.replaceWith(message);
}
const storageKey = `md2html:toc:${window.location.pathname}${window.location.search}`;
let open = false;
function setOpen(value, persist = true) {
open = value;
document.body.classList.toggle('md2html-toc-open', open);
toggle.setAttribute('aria-expanded', String(open));
toggle.setAttribute('aria-label', open ? 'Zatvoriť obsah dokumentu' : 'Zobraziť obsah dokumentu');
panel.setAttribute('aria-hidden', String(!open));
panel.inert = !open;
if (!open && panel.contains(document.activeElement)) toggle.focus();
if (persist) {
try { window.localStorage.setItem(storageKey, String(open)); } catch { /* Storage is optional. */ }
}
}
let savedOpen = false;
try { savedOpen = window.localStorage.getItem(storageKey) === 'true'; } catch { /* Default to closed. */ }
setOpen(savedOpen, false);
panel.hidden = false;
toggle.hidden = false;
backdrop.hidden = false;
document.body.classList.add('md2html-toc-ready');
toggle.addEventListener('click', () => setOpen(!open));
close.addEventListener('click', () => { setOpen(false); toggle.focus(); });
backdrop.addEventListener('click', () => setOpen(false));
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && open) {
setOpen(false);
toggle.focus();
}
});
// The browser may have tried to resolve the fragment before generated IDs existed.
if (window.location.hash) {
try {
const target = document.getElementById(decodeURIComponent(window.location.hash.slice(1)));
if (target && content.contains(target)) target.scrollIntoView();
} catch { /* Ignore malformed URL fragments. */ }
}
})();