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
-1
View File
@@ -10,7 +10,6 @@ ng_module(
module_name = "@angular/docs",
deps = [
"//adev/shared-docs/components",
"//adev/shared-docs/constants",
"//adev/shared-docs/directives",
"//adev/shared-docs/interfaces",
"//adev/shared-docs/pipes",
@@ -6,26 +6,18 @@
<ul class="docs-faceted-list">
<!-- TODO: Hide li elements with class docs-toc-item-h3 for laptop, table and phone screen resolutions -->
@for (item of tableOfContentItems(); track item.id) {
<li
class="docs-faceted-list-item"
[class.docs-toc-item-h2]="item.level === TableOfContentsLevel.H2"
[class.docs-toc-item-h3]="item.level === TableOfContentsLevel.H3"
>
<!-- Not using routerLink + fragment because of: https://github.com/angular/angular/issues/30139 -->
<a
[href]="location.path() + '#' + item.id"
[class.docs-faceted-list-item-active]="item.id === activeItemId()"
>
{{ item.title }}
</a>
</li>
<li class="docs-faceted-list-item" [class.docs-toc-item-h2]="item.level === TableOfContentsLevel.H2"
[class.docs-toc-item-h3]="item.level === TableOfContentsLevel.H3">
<!-- Not using routerLink + fragment because of: https://github.com/angular/angular/issues/30139 -->
<a [href]="location.path() + '#' + item.id" [class.docs-faceted-list-item-active]="item.id === activeItemId()">
{{ item.title }}
</a>
</li>
}
</ul>
</nav>
@if (shouldDisplayScrollToTop()) {
<button type="button" (click)="scrollToTop()">
<docs-icon role="presentation">arrow_upward_alt</docs-icon>
Back to the top
</button>
}
</aside>
<button type="button" (click)="scrollToTop()">
<docs-icon role="presentation">arrow_upward_alt</docs-icon>
Back to the top
</button>
</aside>
@@ -11,8 +11,8 @@ import {
Component,
DestroyRef,
input,
computed,
inject,
afterNextRender,
} from '@angular/core';
import {Location} from '@angular/common';
import {TableOfContentsLevel} from '../../interfaces/index';
@@ -39,12 +39,15 @@ export class TableOfContents {
tableOfContentItems = this.tableOfContentsLoader.tableOfContentItems;
activeItemId = this.scrollSpy.activeItemId;
shouldDisplayScrollToTop = computed(() => !this.scrollSpy.scrollbarThumbOnTop());
TableOfContentsLevel = TableOfContentsLevel;
ngAfterViewInit() {
this.tableOfContentsLoader.buildTableOfContent(this.contentSourceElement());
this.scrollSpy.startListeningToScroll(this.contentSourceElement(), this.destroyRef);
constructor() {
afterNextRender({
read: () => {
this.tableOfContentsLoader.buildTableOfContent(this.contentSourceElement());
this.scrollSpy.setupActiveItemListener(this.contentSourceElement(), this.destroyRef);
},
});
}
scrollToTop(): void {
-28
View File
@@ -1,28 +0,0 @@
load("//tools:defaults.bzl", "ts_library")
package(default_visibility = ["//visibility:private"])
ts_library(
name = "constants",
srcs = [
"index.ts",
],
visibility = [
"//adev/shared-docs:__subpackages__",
],
deps = [
":lib",
],
)
ts_library(
name = "lib",
srcs = glob(
[
"*.ts",
],
exclude = [
"index.ts",
],
),
)
-13
View File
@@ -1,13 +0,0 @@
/*!
* @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
*/
// Used for both the table of contents and the home animation
export const RESIZE_EVENT_DELAY = 500;
// Used for the home animation
export const WEBGL_LOADED_DELAY = 250;
-9
View File
@@ -1,9 +0,0 @@
/*!
* @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
*/
export * from './delay';
-1
View File
@@ -12,7 +12,6 @@
export * from './directives/index';
export * from './components/index';
export * from './constants/index';
export * from './interfaces/index';
export * from './providers/index';
export * from './services/index';
@@ -19,6 +19,4 @@ export interface TableOfContentsItem {
level: TableOfContentsLevel;
/** The unique title for this document page. */
title: string;
/** The top offset px of the heading */
top: number;
}
-1
View File
@@ -25,7 +25,6 @@ ng_module(
],
),
deps = [
"//adev/shared-docs/constants",
"//adev/shared-docs/interfaces",
"//adev/shared-docs/providers",
"//adev/shared-docs/utils",
@@ -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());
});
}
}
@@ -6,153 +6,32 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {DOCUMENT, ViewportScroller} from '@angular/common';
import {
DestroyRef,
EnvironmentInjector,
Injectable,
afterNextRender,
inject,
signal,
} from '@angular/core';
import {RESIZE_EVENT_DELAY} from '../constants/index';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {auditTime, debounceTime, fromEvent, startWith} from 'rxjs';
import {WINDOW} from '../providers/index';
import {ViewportScroller} from '@angular/common';
import {DestroyRef, Injectable, inject, signal} from '@angular/core';
import {TableOfContentsLoader} from './table-of-contents-loader.service';
export const SCROLL_EVENT_DELAY = 20;
@Injectable({providedIn: 'root'})
// The service is responsible for listening for scrolling and resizing,
// thanks to which it sets the active item in the Table of contents
export class TableOfContentsScrollSpy {
private readonly tableOfContentsLoader = inject(TableOfContentsLoader);
private readonly document = inject(DOCUMENT);
private readonly window = inject(WINDOW);
private readonly viewportScroller = inject(ViewportScroller);
private readonly injector = inject(EnvironmentInjector);
private contentSourceElement: HTMLElement | null = null;
private lastContentWidth = 0;
activeItemId = signal<string | null>(null);
scrollbarThumbOnTop = signal<boolean>(true);
startListeningToScroll(
contentSourceElement: HTMLElement | null,
// `destroyRef` is required because the caller may invoke `startListeningToScroll`
// multiple times. Without it, previous event listeners would not be disposed of,
// leading to the accumulation of new event listeners.
destroyRef: DestroyRef,
) {
this.contentSourceElement = contentSourceElement;
this.lastContentWidth = this.getContentWidth();
this.setScrollEventHandlers(destroyRef);
this.setResizeEventHandlers(destroyRef);
destroyRef.onDestroy(() => {
// We also need to clean up the source element once the view that calls
// `startListeningToScroll` is destroyed, as this will keep a reference
// to an element that has been removed from the DOM.
this.contentSourceElement = null;
});
}
scrollToTop(): void {
this.viewportScroller.scrollToPosition([0, 0]);
}
// After window resize, we should update top value of each table content item
private setResizeEventHandlers(destroyRef: DestroyRef) {
fromEvent(this.window, 'resize')
.pipe(debounceTime(RESIZE_EVENT_DELAY), takeUntilDestroyed(destroyRef), startWith())
.subscribe(() => {
this.updateHeadingsTopAfterResize();
});
// We need to observe the height of the docs-viewer because it may change after the
// assets (fonts, images) are loaded. They can (and will) change the y-position of the headings.
const docsViewer = this.document.querySelector('docs-viewer');
if (docsViewer) {
const ref = afterNextRender(
() => {
const resizeObserver = new ResizeObserver(() => this.updateHeadingsTopAfterResize());
resizeObserver.observe(docsViewer);
destroyRef.onDestroy(() => resizeObserver.disconnect());
setupActiveItemListener(contentSourceElement: HTMLElement, destroyRef: DestroyRef): void {
if (contentSourceElement) {
this.tableOfContentsLoader.setupIntersectionObserver(
contentSourceElement,
destroyRef,
(id) => {
this.activeItemId.set(id);
},
{injector: this.injector, manualCleanup: true},
);
destroyRef.onDestroy(() => ref.destroy());
}
}
private updateHeadingsTopAfterResize(): void {
this.lastContentWidth = this.getContentWidth();
const contentElement = this.contentSourceElement;
if (contentElement) {
this.tableOfContentsLoader.updateHeadingsTopValue(contentElement);
this.setActiveItemId();
}
}
private setScrollEventHandlers(destroyRef: DestroyRef): void {
const scroll$ = fromEvent(this.document, 'scroll').pipe(
auditTime(SCROLL_EVENT_DELAY),
takeUntilDestroyed(destroyRef),
);
scroll$.subscribe(() => this.setActiveItemId());
}
private setActiveItemId(): void {
const tableOfContentItems = this.tableOfContentsLoader.tableOfContentItems();
if (tableOfContentItems.length === 0) return;
// Resize could emit scroll event, in that case we could stop setting active item until resize will be finished
if (this.lastContentWidth !== this.getContentWidth()) {
return;
}
const scrollOffset = this.getScrollOffset();
if (scrollOffset === null) return;
for (const [i, currentLink] of tableOfContentItems.entries()) {
const nextLink = tableOfContentItems[i + 1];
// A link is considered active if the page is scrolled past the
// anchor without also being scrolled passed the next link.
const isActive =
scrollOffset >= currentLink.top && (!nextLink || nextLink.top >= scrollOffset);
// When active item was changed then trigger change detection
if (isActive && this.activeItemId() !== currentLink.id) {
this.activeItemId.set(currentLink.id);
return;
}
}
if (scrollOffset < tableOfContentItems[0].top && this.activeItemId() !== null) {
this.activeItemId.set(null);
}
const scrollOffsetZero = scrollOffset === 0;
if (scrollOffsetZero !== this.scrollbarThumbOnTop()) {
// we want to trigger change detection only when the value changes
this.scrollbarThumbOnTop.set(scrollOffsetZero);
}
}
// Gets the scroll offset of the scroll container
private getScrollOffset(): number {
return this.window.scrollY;
}
private getContentWidth(): number {
return this.document.body.clientWidth || Number.MAX_SAFE_INTEGER;
}
}