From b2597ea7b22aa7a2c926a16cd74be94fc6b4fac9 Mon Sep 17 00:00:00 2001 From: Ganesh Srinivasson Date: Mon, 20 Apr 2026 17:10:41 +0200 Subject: [PATCH] Add interactive document outline panel --- src/viewer.css | 203 ++++++++++++++++++++++++++++++++++- src/viewer.js | 281 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 482 insertions(+), 2 deletions(-) diff --git a/src/viewer.css b/src/viewer.css index bca0a0f..b2bc04e 100644 --- a/src/viewer.css +++ b/src/viewer.css @@ -282,6 +282,197 @@ body { justify-content: center; } +.outline-toggle { + position: fixed; + top: 14px; + right: 50px; + width: 32px; + height: 32px; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--bg); + color: var(--muted-text); + font-size: 16px; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s; + z-index: 1000; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.outline-panel { + position: fixed; + box-sizing: border-box; + top: 56px; + right: 14px; + width: 300px; + max-height: calc(100vh - 80px); + overflow: hidden; + overscroll-behavior: contain; + display: none; + flex-direction: column; + padding: 14px 14px 16px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--find-panel-bg); + box-shadow: var(--find-panel-shadow); + backdrop-filter: blur(18px); + z-index: 1050; +} + +.outline-panel--visible { + display: flex; +} + +.outline-panel__header { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 10px; + margin-bottom: 6px; + border-bottom: 1px solid var(--border); +} + +.outline-panel__content { + min-height: 0; + overflow-y: auto; + margin-right: -14px; + padding-top: 4px; + padding-right: 14px; +} + +.outline-panel__header-left { + display: flex; + align-items: baseline; + gap: 8px; +} + +.outline-panel__title { + margin: 0; + font: 600 17px/1.2 "SF Pro Display", "Segoe UI", sans-serif; + color: var(--heading-text); + letter-spacing: -0.01em; +} + +.outline-panel__pin-btn { + border: none; + background: none; + color: var(--muted-text); + font: 400 11px/1 "SF Pro Text", "Segoe UI", sans-serif; + cursor: pointer; + padding: 2px 5px; + border-radius: 4px; + letter-spacing: 0.02em; +} + +.outline-panel__pin-btn:hover { + color: var(--page-text); + background: var(--inline-code-bg); +} + +.outline-panel__pin-btn--active { + color: var(--accent); + font-weight: 600; +} + +.outline-panel__expand-btn { + border: none; + background: none; + color: var(--muted-text); + font: 400 11px/1.2 "SF Pro Text", "Segoe UI", sans-serif; + cursor: pointer; + padding: 3px 6px; + border-radius: 4px; + white-space: nowrap; +} + +.outline-panel__expand-btn:hover { + color: var(--accent); + background: var(--inline-code-bg); +} + +.outline-panel__list { + list-style: none; + margin: 0; + padding: 0; +} + +.outline-panel__list .outline-panel__list { + padding-left: 16px; +} + +.outline-panel__item { + margin: 0 0 3px; +} + +.outline-panel__row { + display: flex; + align-items: flex-start; + border-radius: 5px; + padding: 3px 4px; + transition: background 0.1s; +} + +.outline-panel__row:hover { + background: var(--inline-code-bg); +} + +.outline-panel__toggle { + flex-shrink: 0; + width: 18px; + border: none; + background: none; + color: var(--muted-text); + font-size: 12px; + cursor: pointer; + padding: 0; + line-height: 1.7; + text-align: center; + align-self: flex-start; +} + +.outline-panel__spacer { + flex-shrink: 0; + width: 18px; +} + +.outline-panel__link { + flex: 1; + min-width: 0; + border: none; + background: none; + color: var(--page-text); + font: 400 14px/1.65 "SF Pro Text", "Segoe UI", sans-serif; + text-align: left; + cursor: pointer; + padding: 1px 4px; + white-space: normal; + word-break: break-word; + text-decoration: none; +} + +.outline-panel__row:hover .outline-panel__link { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.outline-panel__children--collapsed { + display: none; +} + +.outline-panel__empty { + color: var(--muted-text); + font: 400 12px/1.5 "SF Pro Text", "Segoe UI", sans-serif; + padding: 4px; + margin: 0; +} + .find-panel { position: fixed; top: 14px; @@ -358,11 +549,13 @@ body { color: var(--find-active-text); } -body:hover .theme-toggle { +body:hover .theme-toggle, +body:hover .outline-toggle { opacity: 0.5; } -.theme-toggle:hover { +.theme-toggle:hover, +.outline-toggle:hover { opacity: 1 !important; color: var(--page-text); } @@ -382,6 +575,10 @@ body:hover .theme-toggle { width: 100%; min-width: 0; } + + .outline-panel { + width: 300px; + } } @media print { @@ -391,6 +588,8 @@ body:hover .theme-toggle { } .theme-toggle, + .outline-toggle, + .outline-panel, .find-panel { display: none; } diff --git a/src/viewer.js b/src/viewer.js index 7a0e463..c7c9591 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -10,6 +10,15 @@ inputEl: null, countEl: null, }; + + const outlineState = { + panelEl: null, + expandBtnEl: null, + toggleBtnEl: null, + pinBtnEl: null, + open: false, + pinned: false, + }; let renderedContentHtml = ""; let renderedDocumentTitle = document.title; @@ -78,6 +87,275 @@ updateToggleButton(); } + function buildOutlineTree(headings) { + const root = []; + const stack = []; + + for (const el of headings) { + const level = parseInt(el.tagName[1], 10); + const node = { el, level, children: [] }; + + while (stack.length > 0 && stack[stack.length - 1].level >= level) { + stack.pop(); + } + + if (stack.length === 0) { + root.push(node); + } else { + stack[stack.length - 1].node.children.push(node); + } + + stack.push({ node, level }); + } + + return root; + } + + function renderOutlineList(nodes) { + const ul = document.createElement("ul"); + ul.className = "outline-panel__list"; + + for (const node of nodes) { + const li = document.createElement("li"); + li.className = "outline-panel__item"; + + const row = document.createElement("div"); + row.className = "outline-panel__row"; + + let childList = null; + + if (node.children.length > 0) { + const triBtn = document.createElement("button"); + triBtn.className = "outline-panel__toggle"; + triBtn.textContent = "\u2304"; + triBtn.setAttribute("aria-label", "Collapse"); + triBtn.setAttribute("type", "button"); + + childList = renderOutlineList(node.children); + childList.dataset.outlineChildren = "1"; + + triBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const isCollapsed = childList.classList.toggle("outline-panel__children--collapsed"); + triBtn.textContent = isCollapsed ? "\u203A" : "\u2304"; + triBtn.setAttribute("aria-label", isCollapsed ? "Expand" : "Collapse"); + updateExpandCollapseButton(); + }); + + row.appendChild(triBtn); + } else { + const spacer = document.createElement("span"); + spacer.className = "outline-panel__spacer"; + row.appendChild(spacer); + } + + const linkBtn = document.createElement("button"); + linkBtn.className = "outline-panel__link"; + linkBtn.textContent = node.el.textContent.trim(); + linkBtn.setAttribute("type", "button"); + linkBtn.addEventListener("click", () => { + node.el.scrollIntoView({ behavior: "smooth", block: "start" }); + if (!outlineState.pinned) closeOutline(); + }); + + row.appendChild(linkBtn); + li.appendChild(row); + if (childList) li.appendChild(childList); + ul.appendChild(li); + } + + return ul; + } + + function updateExpandCollapseButton() { + if (!outlineState.expandBtnEl || !outlineState.panelEl) return; + const collapsed = outlineState.panelEl.querySelectorAll(".outline-panel__children--collapsed"); + outlineState.expandBtnEl.textContent = collapsed.length > 0 ? "Expand all" : "Collapse all"; + } + + function syncToggleIcons() { + for (const btn of outlineState.panelEl.querySelectorAll(".outline-panel__toggle")) { + const li = btn.closest(".outline-panel__item"); + const childList = li && li.querySelector(":scope > [data-outline-children]"); + if (childList) { + const isCollapsed = childList.classList.contains("outline-panel__children--collapsed"); + btn.textContent = isCollapsed ? "\u203A" : "\u2304"; + btn.setAttribute("aria-label", isCollapsed ? "Expand" : "Collapse"); + } + } + } + + function expandCollapseAll() { + if (!outlineState.panelEl) return; + const allChildLists = outlineState.panelEl.querySelectorAll("[data-outline-children]"); + const collapsed = outlineState.panelEl.querySelectorAll(".outline-panel__children--collapsed"); + + if (collapsed.length > 0) { + for (const list of allChildLists) { + list.classList.remove("outline-panel__children--collapsed"); + } + } else { + const content = outlineState.panelEl.querySelector(".outline-panel__content"); + const rootUl = content && content.querySelector(".outline-panel__list"); + const singleRoot = rootUl && rootUl.children.length === 1; + + if (singleRoot) { + const firstLevel = new Set( + Array.from(rootUl.querySelectorAll(":scope > li > [data-outline-children]")) + ); + for (const list of allChildLists) { + if (!firstLevel.has(list)) list.classList.add("outline-panel__children--collapsed"); + } + } else { + for (const list of allChildLists) { + list.classList.add("outline-panel__children--collapsed"); + } + } + } + + syncToggleIcons(); + updateExpandCollapseButton(); + } + + function rebuildOutline() { + if (!outlineState.panelEl) return; + + const container = outlineState.panelEl.querySelector(".outline-panel__content"); + container.innerHTML = ""; + + const headings = Array.from(contentEl.querySelectorAll("h1, h2, h3, h4, h5, h6")); + + if (headings.length === 0) { + const empty = document.createElement("p"); + empty.className = "outline-panel__empty"; + empty.textContent = "No headings"; + container.appendChild(empty); + outlineState.expandBtnEl.style.display = "none"; + return; + } + + outlineState.expandBtnEl.style.display = ""; + container.appendChild(renderOutlineList(buildOutlineTree(headings))); + updateExpandCollapseButton(); + } + + function closeOutline() { + if (!outlineState.panelEl) return; + outlineState.panelEl.classList.remove("outline-panel--visible"); + outlineState.open = false; + if (outlineState.toggleBtnEl) { + outlineState.toggleBtnEl.setAttribute("aria-expanded", "false"); + outlineState.toggleBtnEl.setAttribute("aria-label", "Show outline"); + } + } + + function openOutline() { + if (!outlineState.panelEl) return; + closeFindBar(); + outlineState.panelEl.classList.add("outline-panel--visible"); + outlineState.open = true; + if (outlineState.toggleBtnEl) { + outlineState.toggleBtnEl.setAttribute("aria-expanded", "true"); + outlineState.toggleBtnEl.setAttribute("aria-label", "Hide outline"); + } + } + + function toggleOutline() { + if (outlineState.open) { + closeOutline(); + } else { + openOutline(); + } + } + + function togglePin() { + outlineState.pinned = !outlineState.pinned; + if (outlineState.pinBtnEl) { + outlineState.pinBtnEl.classList.toggle("outline-panel__pin-btn--active", outlineState.pinned); + outlineState.pinBtnEl.textContent = outlineState.pinned ? "pinned" : "pin"; + outlineState.pinBtnEl.setAttribute("aria-pressed", String(outlineState.pinned)); + } + } + + function closeOutlineOnOutsideClick(event) { + if (!outlineState.open || outlineState.pinned || !outlineState.panelEl) return; + + const target = event.target; + if (!(target instanceof Node)) return; + + if ( + outlineState.panelEl.contains(target) || + (outlineState.toggleBtnEl && outlineState.toggleBtnEl.contains(target)) + ) { + return; + } + + closeOutline(); + } + + function createOutlinePanel() { + const btn = document.createElement("button"); + btn.className = "outline-toggle"; + btn.textContent = "\u2630"; + btn.setAttribute("type", "button"); + btn.setAttribute("aria-label", "Show outline"); + btn.setAttribute("aria-expanded", "false"); + btn.addEventListener("click", toggleOutline); + document.body.appendChild(btn); + outlineState.toggleBtnEl = btn; + + const panel = document.createElement("div"); + panel.className = "outline-panel"; + panel.setAttribute("role", "navigation"); + panel.setAttribute("aria-label", "Document outline"); + + const header = document.createElement("div"); + header.className = "outline-panel__header"; + + const headerLeft = document.createElement("div"); + headerLeft.className = "outline-panel__header-left"; + + const title = document.createElement("h2"); + title.className = "outline-panel__title"; + title.textContent = "Outline"; + + const pinBtn = document.createElement("button"); + pinBtn.className = "outline-panel__pin-btn"; + pinBtn.setAttribute("type", "button"); + pinBtn.setAttribute("aria-label", "Pin panel open"); + pinBtn.setAttribute("aria-pressed", "false"); + pinBtn.textContent = "pin"; + pinBtn.addEventListener("click", togglePin); + outlineState.pinBtnEl = pinBtn; + + headerLeft.append(title, pinBtn); + + const expandBtn = document.createElement("button"); + expandBtn.className = "outline-panel__expand-btn"; + expandBtn.setAttribute("type", "button"); + expandBtn.textContent = "Collapse all"; + expandBtn.addEventListener("click", expandCollapseAll); + outlineState.expandBtnEl = expandBtn; + + header.append(headerLeft, expandBtn); + + const content = document.createElement("div"); + content.className = "outline-panel__content"; + content.addEventListener("wheel", function (e) { + const atTop = this.scrollTop <= 0 && e.deltaY < 0; + const atBottom = this.scrollTop + this.clientHeight >= this.scrollHeight && e.deltaY > 0; + if (atTop || atBottom) e.preventDefault(); + }, { passive: false }); + + panel.appendChild(header); + panel.appendChild(content); + document.body.appendChild(panel); + outlineState.panelEl = panel; + document.addEventListener("pointerdown", closeOutlineOnOutsideClick); + + rebuildOutline(); + } + function updateSearchCountLabel() { if (!searchState.countEl) return; @@ -100,6 +378,7 @@ finalizeLinks(contentEl); finalizeImages(contentEl); document.title = renderedDocumentTitle; + rebuildOutline(); } function clearSearchSelection() { @@ -270,6 +549,7 @@ function openFindBar() { if (!searchState.panelEl) return; + closeOutline(); searchState.panelEl.classList.add("find-panel--visible"); searchState.inputEl.focus(); searchState.inputEl.select(); @@ -476,6 +756,7 @@ window.mdvFindPreviousMatch = () => jumpToSearchMatch(-1); window.mdvCloseFindBar = closeFindBar; + createOutlinePanel(); createSearchPanel(); createToggleButton(); })();