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", module_name = "@angular/docs",
deps = [ deps = [
"//adev/shared-docs/components", "//adev/shared-docs/components",
"//adev/shared-docs/constants",
"//adev/shared-docs/directives", "//adev/shared-docs/directives",
"//adev/shared-docs/interfaces", "//adev/shared-docs/interfaces",
"//adev/shared-docs/pipes", "//adev/shared-docs/pipes",
@@ -6,26 +6,18 @@
<ul class="docs-faceted-list"> <ul class="docs-faceted-list">
<!-- TODO: Hide li elements with class docs-toc-item-h3 for laptop, table and phone screen resolutions --> <!-- TODO: Hide li elements with class docs-toc-item-h3 for laptop, table and phone screen resolutions -->
@for (item of tableOfContentItems(); track item.id) { @for (item of tableOfContentItems(); track item.id) {
<li <li class="docs-faceted-list-item" [class.docs-toc-item-h2]="item.level === TableOfContentsLevel.H2"
class="docs-faceted-list-item" [class.docs-toc-item-h3]="item.level === TableOfContentsLevel.H3">
[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 --> <!-- Not using routerLink + fragment because of: https://github.com/angular/angular/issues/30139 -->
<a <a [href]="location.path() + '#' + item.id" [class.docs-faceted-list-item-active]="item.id === activeItemId()">
[href]="location.path() + '#' + item.id"
[class.docs-faceted-list-item-active]="item.id === activeItemId()"
>
{{ item.title }} {{ item.title }}
</a> </a>
</li> </li>
} }
</ul> </ul>
</nav> </nav>
@if (shouldDisplayScrollToTop()) {
<button type="button" (click)="scrollToTop()"> <button type="button" (click)="scrollToTop()">
<docs-icon role="presentation">arrow_upward_alt</docs-icon> <docs-icon role="presentation">arrow_upward_alt</docs-icon>
Back to the top Back to the top
</button> </button>
}
</aside> </aside>
@@ -11,8 +11,8 @@ import {
Component, Component,
DestroyRef, DestroyRef,
input, input,
computed,
inject, inject,
afterNextRender,
} from '@angular/core'; } from '@angular/core';
import {Location} from '@angular/common'; import {Location} from '@angular/common';
import {TableOfContentsLevel} from '../../interfaces/index'; import {TableOfContentsLevel} from '../../interfaces/index';
@@ -39,12 +39,15 @@ export class TableOfContents {
tableOfContentItems = this.tableOfContentsLoader.tableOfContentItems; tableOfContentItems = this.tableOfContentsLoader.tableOfContentItems;
activeItemId = this.scrollSpy.activeItemId; activeItemId = this.scrollSpy.activeItemId;
shouldDisplayScrollToTop = computed(() => !this.scrollSpy.scrollbarThumbOnTop());
TableOfContentsLevel = TableOfContentsLevel; TableOfContentsLevel = TableOfContentsLevel;
ngAfterViewInit() { constructor() {
afterNextRender({
read: () => {
this.tableOfContentsLoader.buildTableOfContent(this.contentSourceElement()); this.tableOfContentsLoader.buildTableOfContent(this.contentSourceElement());
this.scrollSpy.startListeningToScroll(this.contentSourceElement(), this.destroyRef); this.scrollSpy.setupActiveItemListener(this.contentSourceElement(), this.destroyRef);
},
});
} }
scrollToTop(): void { 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 './directives/index';
export * from './components/index'; export * from './components/index';
export * from './constants/index';
export * from './interfaces/index'; export * from './interfaces/index';
export * from './providers/index'; export * from './providers/index';
export * from './services/index'; export * from './services/index';
@@ -19,6 +19,4 @@ export interface TableOfContentsItem {
level: TableOfContentsLevel; level: TableOfContentsLevel;
/** The unique title for this document page. */ /** The unique title for this document page. */
title: string; title: string;
/** The top offset px of the heading */
top: number;
} }
-1
View File
@@ -25,7 +25,6 @@ ng_module(
], ],
), ),
deps = [ deps = [
"//adev/shared-docs/constants",
"//adev/shared-docs/interfaces", "//adev/shared-docs/interfaces",
"//adev/shared-docs/providers", "//adev/shared-docs/providers",
"//adev/shared-docs/utils", "//adev/shared-docs/utils",
@@ -6,10 +6,11 @@
* found in the LICENSE file at https://angular.dev/license * found in the LICENSE file at https://angular.dev/license
*/ */
import {DOCUMENT, isPlatformBrowser} from '@angular/common'; import {DOCUMENT} from '@angular/common';
import {inject, signal, Injectable, PLATFORM_ID} from '@angular/core'; import {inject, signal, Injectable, PLATFORM_ID, DestroyRef} from '@angular/core';
import {TableOfContentsItem, TableOfContentsLevel} from '../interfaces/index'; import {TableOfContentsItem, TableOfContentsLevel} from '../interfaces/index';
import {on} from 'events';
/** /**
* Name of an attribute that is set on an element that should be * 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[]); readonly tableOfContentItems = signal([] as TableOfContentsItem[]);
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
buildTableOfContent(docElement: Element): void { buildTableOfContent(docElement: Element): void {
const headings = this.getHeadings(docElement); const headings = this.getHeadings(docElement);
const tocList: TableOfContentsItem[] = headings.map((heading) => ({ const tocList: TableOfContentsItem[] = headings.map((heading) => ({
id: heading.id, id: heading.id,
level: heading.tagName.toLowerCase() as TableOfContentsLevel, level: heading.tagName.toLowerCase() as TableOfContentsLevel,
title: this.getHeadingTitle(heading), title: this.getHeadingTitle(heading),
top: this.calculateTop(heading),
})); }));
this.tableOfContentItems.set(tocList); 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 { private getHeadingTitle(heading: HTMLHeadingElement): string {
const div: HTMLDivElement = this.document.createElement('div'); const div: HTMLDivElement = this.document.createElement('div');
div.innerHTML = heading.innerHTML; div.innerHTML = heading.innerHTML;
@@ -79,11 +61,31 @@ export class TableOfContentsLoader {
); );
} }
private calculateTop(heading: HTMLHeadingElement): number { /**
if (!isPlatformBrowser(this.platformId)) return 0; * The methods setups several IntersectionObservers to determine when a heading is at the top of
return ( * the viewport. Using an IntersectionObserver is more efficient than reading DOM position
Math.floor(heading.offsetTop > 0 ? heading.offsetTop : heading.getClientRects()[0]?.top) - * as it won't trigger any reflow.
this.toleranceThreshold */
); 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 * found in the LICENSE file at https://angular.dev/license
*/ */
import {DOCUMENT, ViewportScroller} from '@angular/common'; import {ViewportScroller} from '@angular/common';
import { import {DestroyRef, Injectable, inject, signal} from '@angular/core';
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 {TableOfContentsLoader} from './table-of-contents-loader.service'; import {TableOfContentsLoader} from './table-of-contents-loader.service';
export const SCROLL_EVENT_DELAY = 20;
@Injectable({providedIn: 'root'}) @Injectable({providedIn: 'root'})
// The service is responsible for listening for scrolling and resizing, // The service is responsible for listening for scrolling and resizing,
// thanks to which it sets the active item in the Table of contents // thanks to which it sets the active item in the Table of contents
export class TableOfContentsScrollSpy { export class TableOfContentsScrollSpy {
private readonly tableOfContentsLoader = inject(TableOfContentsLoader); private readonly tableOfContentsLoader = inject(TableOfContentsLoader);
private readonly document = inject(DOCUMENT);
private readonly window = inject(WINDOW);
private readonly viewportScroller = inject(ViewportScroller); private readonly viewportScroller = inject(ViewportScroller);
private readonly injector = inject(EnvironmentInjector);
private contentSourceElement: HTMLElement | null = null;
private lastContentWidth = 0;
activeItemId = signal<string | null>(null); 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 { scrollToTop(): void {
this.viewportScroller.scrollToPosition([0, 0]); this.viewportScroller.scrollToPosition([0, 0]);
} }
// After window resize, we should update top value of each table content item setupActiveItemListener(contentSourceElement: HTMLElement, destroyRef: DestroyRef): void {
private setResizeEventHandlers(destroyRef: DestroyRef) { if (contentSourceElement) {
fromEvent(this.window, 'resize') this.tableOfContentsLoader.setupIntersectionObserver(
.pipe(debounceTime(RESIZE_EVENT_DELAY), takeUntilDestroyed(destroyRef), startWith()) contentSourceElement,
.subscribe(() => { destroyRef,
this.updateHeadingsTopAfterResize(); (id) => {
}); this.activeItemId.set(id);
// 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());
}, },
{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;
}
} }