/*! * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import {DOCUMENT} from '@angular/common'; import {inject, signal, DestroyRef, Service} from '@angular/core'; import {TableOfContentsItem, TableOfContentsLevel} from '../interfaces/index'; /** * Name of an attribute that is set on an element that should be * excluded from the `TableOfContentsLoader` lookup. This is needed * to exempt SSR'ed content of the `TableOfContents` component from * being inspected and accidentally pulling more content into ToC. */ export const TOC_SKIP_CONTENT_MARKER = 'toc-skip-content'; @Service() export class TableOfContentsLoader { // There are some cases when default browser anchor scrolls a little above the // heading In that cases wrong item was selected. The value found by trial and // error. readonly toleranceThreshold = 40; readonly tableOfContentItems = signal([] as TableOfContentsItem[]); private readonly document = inject(DOCUMENT); buildTableOfContent(docElement: Element): void { const headings = this.getHeadings(docElement); const tocList: TableOfContentsItem[] = headings.map((heading) => ({ id: heading.id, level: heading.tagName.toLowerCase() as TableOfContentsLevel, title: this.getHeadingTitle(heading), })); this.tableOfContentItems.set(tocList); } private getHeadingTitle(heading: HTMLHeadingElement): string { const div: HTMLDivElement = this.document.createElement('div'); div.innerHTML = heading.innerHTML; // Remove any existing copy link buttons to avoid including their text in the title div.querySelectorAll('docs-copy-link-button').forEach((el) => el.remove()); return (div.textContent || '').trim(); } // Get all headings (h2 and h3) with ids, which are not children of the // docs-example-viewer component. private getHeadings(element: Element): HTMLHeadingElement[] { return Array.from( element.querySelectorAll( `h2[id]:not(docs-example-viewer h2):not([${TOC_SKIP_CONTENT_MARKER}]),` + `h3[id]:not(docs-example-viewer h3):not([${TOC_SKIP_CONTENT_MARKER}])`, ), ); } /** * The methods setups several IntersectionObservers to determine when a heading is at the top of * the viewport. Using an IntersectionObserver is more efficient than reading DOM position * as it won't trigger any reflow. */ setupIntersectionObserver( element: Element, destroyRef: DestroyRef, onActiveId: (id: string) => void, ): void { // If we're at the top the we need the default active id to be the first heading. const headings = this.getHeadings(element); onActiveId(headings[0].id); headings.forEach((heading) => { const ioConfiguration = { /** * This rootMargin creates a horizontal line at 5% from the top of the viewport * that will help trigger an intersection at that the very point. */ rootMargin: '0% 0% -95% 0%', /** 0 is the default */ threshold: 0, }; const observer = new IntersectionObserver((entries, o) => { if (entries[0].isIntersecting) { onActiveId(entries[0].target.id); } }, ioConfiguration); observer.observe(heading); destroyRef.onDestroy(() => observer.disconnect()); }); } }