docs(docs-infra): Use intersectionObserver for TOC (#60166)

Using IntersectionObservers simplfies the code and doesn't rely on APIs that will trigger a reflow.

PR Close #60166
This commit is contained in:
Matthieu Riegler
2025-03-01 03:21:33 +01:00
committed by Andrew Kushnir
parent 1b6b9e283f
commit 68368f6214
11 changed files with 59 additions and 238 deletions
@@ -6,10 +6,11 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {DOCUMENT, isPlatformBrowser} from '@angular/common';
import {inject, signal, Injectable, PLATFORM_ID} from '@angular/core';
import {DOCUMENT} from '@angular/common';
import {inject, signal, Injectable, PLATFORM_ID, DestroyRef} from '@angular/core';
import {TableOfContentsItem, TableOfContentsLevel} from '../interfaces/index';
import {on} from 'events';
/**
* Name of an attribute that is set on an element that should be
@@ -29,38 +30,19 @@ export class TableOfContentsLoader {
readonly tableOfContentItems = signal([] as TableOfContentsItem[]);
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
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),
top: this.calculateTop(heading),
}));
this.tableOfContentItems.set(tocList);
}
// Update top value of heading, it should be executed after window resize
updateHeadingsTopValue(element: HTMLElement): void {
const headings = this.getHeadings(element);
const updatedTopValues = new Map<string, number>();
for (const heading of headings) {
updatedTopValues.set(heading.id, this.calculateTop(heading));
}
this.tableOfContentItems.update((oldItems) => {
let newItems = [...oldItems];
for (const item of newItems) {
item.top = updatedTopValues.get(item.id) ?? 0;
}
return newItems;
});
}
private getHeadingTitle(heading: HTMLHeadingElement): string {
const div: HTMLDivElement = this.document.createElement('div');
div.innerHTML = heading.innerHTML;
@@ -79,11 +61,31 @@ export class TableOfContentsLoader {
);
}
private calculateTop(heading: HTMLHeadingElement): number {
if (!isPlatformBrowser(this.platformId)) return 0;
return (
Math.floor(heading.offsetTop > 0 ? heading.offsetTop : heading.getClientRects()[0]?.top) -
this.toleranceThreshold
);
/**
* 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 {
this.getHeadings(element).forEach((heading) => {
const ioConfiguration = {
/**
* This rootMargin creates a horizontal line at 20% from the top of the viewport
* that will help trigger an intersection at that the very point.
*/
rootMargin: '-5% 0% -95% 0%',
/** 0 is the default */
threshold: 0,
};
const observer = new IntersectionObserver((entries, o) => {
onActiveId(entries[0].target.id);
}, ioConfiguration);
observer.observe(heading);
destroyRef.onDestroy(() => observer.disconnect());
});
}
}