2024-07-23 16:50:12 +00:00
|
|
|
/*!
|
|
|
|
|
* @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
|
|
|
|
|
*/
|
|
|
|
|
|
2025-03-01 03:21:33 +01:00
|
|
|
import {DOCUMENT} from '@angular/common';
|
2026-04-23 13:40:27 +02:00
|
|
|
import {inject, signal, DestroyRef, Service} from '@angular/core';
|
2024-07-23 16:50:12 +00:00
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
|
2026-04-23 13:40:27 +02:00
|
|
|
@Service()
|
2024-07-23 16:50:12 +00:00
|
|
|
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);
|
2025-03-01 03:21:33 +01:00
|
|
|
|
2024-07-23 16:50:12 +00:00
|
|
|
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');
|
2025-11-30 11:13:14 -05:00
|
|
|
|
2024-07-23 16:50:12 +00:00
|
|
|
div.innerHTML = heading.innerHTML;
|
2025-11-30 11:13:14 -05:00
|
|
|
// Remove any existing copy link buttons to avoid including their text in the title
|
|
|
|
|
div.querySelectorAll('docs-copy-link-button').forEach((el) => el.remove());
|
2024-07-23 16:50:12 +00:00
|
|
|
|
|
|
|
|
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<HTMLHeadingElement>(
|
|
|
|
|
`h2[id]:not(docs-example-viewer h2):not([${TOC_SKIP_CONTENT_MARKER}]),` +
|
|
|
|
|
`h3[id]:not(docs-example-viewer h3):not([${TOC_SKIP_CONTENT_MARKER}])`,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-01 03:21:33 +01:00
|
|
|
/**
|
|
|
|
|
* 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 {
|
2025-03-06 10:27:00 +01:00
|
|
|
// 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) => {
|
2025-03-01 03:21:33 +01:00
|
|
|
const ioConfiguration = {
|
|
|
|
|
/**
|
2025-03-06 10:27:00 +01:00
|
|
|
* This rootMargin creates a horizontal line at 5% from the top of the viewport
|
2025-03-01 03:21:33 +01:00
|
|
|
* that will help trigger an intersection at that the very point.
|
|
|
|
|
*/
|
2025-03-06 10:27:00 +01:00
|
|
|
rootMargin: '0% 0% -95% 0%',
|
2025-03-01 03:21:33 +01:00
|
|
|
/** 0 is the default */
|
|
|
|
|
threshold: 0,
|
|
|
|
|
};
|
|
|
|
|
const observer = new IntersectionObserver((entries, o) => {
|
2025-03-06 10:27:00 +01:00
|
|
|
if (entries[0].isIntersecting) {
|
|
|
|
|
onActiveId(entries[0].target.id);
|
|
|
|
|
}
|
2025-03-01 03:21:33 +01:00
|
|
|
}, ioConfiguration);
|
|
|
|
|
observer.observe(heading);
|
|
|
|
|
destroyRef.onDestroy(() => observer.disconnect());
|
|
|
|
|
});
|
2024-07-23 16:50:12 +00:00
|
|
|
}
|
|
|
|
|
}
|