| 
 | 1 | +---  | 
 | 2 | +interface Props {  | 
 | 3 | +  sections: Array<{  | 
 | 4 | +    id: string;  | 
 | 5 | +    title: string;  | 
 | 6 | +  }>;  | 
 | 7 | +  title?: string;  | 
 | 8 | +}  | 
 | 9 | +
  | 
 | 10 | +const { sections, title = "On this page" } = Astro.props;  | 
 | 11 | +---  | 
 | 12 | + | 
 | 13 | +<nav class="sidebar-nav">  | 
 | 14 | +  <p>{title}</p>  | 
 | 15 | +  <ul>  | 
 | 16 | +    {sections.map((section) => (  | 
 | 17 | +      <li>  | 
 | 18 | +        <a href={`#${section.id}`} data-section={section.id}>  | 
 | 19 | +          {section.title}  | 
 | 20 | +        </a>  | 
 | 21 | +      </li>  | 
 | 22 | +    ))}  | 
 | 23 | +  </ul>  | 
 | 24 | +</nav>  | 
 | 25 | + | 
 | 26 | +<style lang="scss">  | 
 | 27 | +  .sidebar-nav {  | 
 | 28 | +    height: fit-content;  | 
 | 29 | +    padding: 1rem;  | 
 | 30 | +    background: var(--surface-frame-bg);  | 
 | 31 | +    border-radius: 8px;  | 
 | 32 | +    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);  | 
 | 33 | + | 
 | 34 | +    &::-webkit-scrollbar {  | 
 | 35 | +      width: 4px;  | 
 | 36 | +    }  | 
 | 37 | + | 
 | 38 | +    &::-webkit-scrollbar-track {  | 
 | 39 | +      background: transparent;  | 
 | 40 | +    }  | 
 | 41 | + | 
 | 42 | +    &::-webkit-scrollbar-thumb {  | 
 | 43 | +      background: var(--text-body-secondary);  | 
 | 44 | +      border-radius: 2px;  | 
 | 45 | +    }  | 
 | 46 | +      | 
 | 47 | +    p{  | 
 | 48 | +      margin-bottom: 1rem;  | 
 | 49 | +      border-bottom: 1px solid var(--text-body-secondary);  | 
 | 50 | +      @include typography(paragraph);  | 
 | 51 | +      font-size: calc(0.75rem * var(--font-scale-factor));  | 
 | 52 | +    }  | 
 | 53 | + | 
 | 54 | +    ul {  | 
 | 55 | +      list-style: none;  | 
 | 56 | +      padding: 0;  | 
 | 57 | +      margin: 0;  | 
 | 58 | +      display: flex;  | 
 | 59 | +      flex-direction: column;  | 
 | 60 | +      gap: 0.5rem;  | 
 | 61 | + | 
 | 62 | +      li {  | 
 | 63 | +        margin: 0;  | 
 | 64 | +        padding: 0;  | 
 | 65 | + | 
 | 66 | +        a {  | 
 | 67 | +          display: block;  | 
 | 68 | +          padding: 0.2rem 0.5rem;  | 
 | 69 | +          text-decoration: none;  | 
 | 70 | +          color: var(--text-body-primary);  | 
 | 71 | +          transition: all 0.2s ease;  | 
 | 72 | +          @include typography(menu);  | 
 | 73 | +          font-size: calc(0.9rem * var(--font-scale-factor));  | 
 | 74 | + | 
 | 75 | +          &:hover {  | 
 | 76 | +            background: var(--surface-main-primary);  | 
 | 77 | +            color: white;  | 
 | 78 | +          }  | 
 | 79 | + | 
 | 80 | +          &.active {  | 
 | 81 | +            border-right: 4px solid var(--surface-main-primary);  | 
 | 82 | +          }  | 
 | 83 | +        }  | 
 | 84 | +      }  | 
 | 85 | +    }  | 
 | 86 | +  }  | 
 | 87 | +</style>  | 
 | 88 | + | 
 | 89 | +<script>  | 
 | 90 | +  // Only run this script if the sidebar nav exists on the page  | 
 | 91 | +  const sidebarNav = document.querySelector('.sidebar-nav');  | 
 | 92 | +  if (sidebarNav) {  | 
 | 93 | +    // Initialize sections visibility and currently active section  | 
 | 94 | +    const visibilityMap: Record<string, number> = {};  | 
 | 95 | +    let currentActiveSection: string | null = null;  | 
 | 96 | + | 
 | 97 | +    // Thresholds for visibility changes  | 
 | 98 | +    const thresholdsArray = Array.from({ length: 101 }, (_, idx) => idx / 100);  | 
 | 99 | + | 
 | 100 | +    // Intersection Observer logic  | 
 | 101 | +    const intersectionCallback = (entries: IntersectionObserverEntry[]) => {  | 
 | 102 | +      entries.forEach((entry: IntersectionObserverEntry) => {  | 
 | 103 | +        if (entry.target.id) {  | 
 | 104 | +          visibilityMap[entry.target.id] = entry.intersectionRatio;  | 
 | 105 | +        }  | 
 | 106 | +      });  | 
 | 107 | + | 
 | 108 | +      // Determine the most visible section  | 
 | 109 | +      const visibleSections = Object.entries(visibilityMap)  | 
 | 110 | +        .filter(([, ratio]: [string, number]) => ratio > 0)  | 
 | 111 | +        .sort((a: [string, number], b: [string, number]) => {  | 
 | 112 | +          // Primary: sections with >= 0.7 ratio first, secondary: vertical order  | 
 | 113 | +          const aHighVis = a[1] >= 0.7 ? 1 : 0;  | 
 | 114 | +          const bHighVis = b[1] >= 0.7 ? 1 : 0;  | 
 | 115 | +          if (bHighVis !== aHighVis) {  | 
 | 116 | +            return bHighVis - aHighVis;  | 
 | 117 | +          }  | 
 | 118 | +            | 
 | 119 | +          // Secondary sort by vertical position  | 
 | 120 | +          const aElement = document.getElementById(a[0]);  | 
 | 121 | +          const bElement = document.getElementById(b[0]);  | 
 | 122 | +          if (aElement && bElement) {  | 
 | 123 | +            return aElement.offsetTop - bElement.offsetTop;  | 
 | 124 | +          }  | 
 | 125 | +          return 0;  | 
 | 126 | +        });  | 
 | 127 | + | 
 | 128 | +      const newActiveSection = visibleSections.length ? visibleSections[0][0] : null;  | 
 | 129 | +      if (newActiveSection && newActiveSection !== currentActiveSection) {  | 
 | 130 | +        document.querySelectorAll('.sidebar-nav a.active').forEach(el => el.classList.remove('active'));  | 
 | 131 | +        document.querySelector(`.sidebar-nav a[data-section="${newActiveSection}"]`)?.classList.add('active');  | 
 | 132 | +        currentActiveSection = newActiveSection;  | 
 | 133 | +      }  | 
 | 134 | +    };  | 
 | 135 | + | 
 | 136 | +    const observerConfig: IntersectionObserverInit = {  | 
 | 137 | +      root: null,  | 
 | 138 | +      rootMargin: "0px 0px -30% 0px",  | 
 | 139 | +      threshold: thresholdsArray,  | 
 | 140 | +    };  | 
 | 141 | + | 
 | 142 | +    const observer = new IntersectionObserver(intersectionCallback, observerConfig);  | 
 | 143 | + | 
 | 144 | +    // Observe sections  | 
 | 145 | +    const sectionsToObserve = document.querySelectorAll(".with-sidebar-nav section[id]");  | 
 | 146 | +    sectionsToObserve.forEach((section) => {  | 
 | 147 | +      const sectionId = section.getAttribute("id");  | 
 | 148 | +      if (sectionId) {  | 
 | 149 | +        visibilityMap[sectionId] = 0;  | 
 | 150 | +        observer.observe(section);  | 
 | 151 | +      }  | 
 | 152 | +    });  | 
 | 153 | + | 
 | 154 | +    // Initial intersection calculation  | 
 | 155 | +    setTimeout(() => {  | 
 | 156 | +      intersectionCallback([]);  | 
 | 157 | +    }, 100);  | 
 | 158 | + | 
 | 159 | +    // Smooth-scroll event listener  | 
 | 160 | +    document.querySelectorAll(".sidebar-nav a").forEach((link) => {  | 
 | 161 | +      link.addEventListener("click", (e) => {  | 
 | 162 | +        e.preventDefault();  | 
 | 163 | +        const targetId = link.getAttribute("href")?.substring(1);  | 
 | 164 | +        if (targetId) {  | 
 | 165 | +          const targetElement = document.getElementById(targetId);  | 
 | 166 | +          if (targetElement) {  | 
 | 167 | +            targetElement.scrollIntoView({  | 
 | 168 | +              behavior: "smooth",  | 
 | 169 | +              block: "start",  | 
 | 170 | +            });  | 
 | 171 | +          }  | 
 | 172 | +        }  | 
 | 173 | +      });  | 
 | 174 | +    });  | 
 | 175 | +  }  | 
 | 176 | +</script>  | 
 | 177 | + | 
0 commit comments