mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
Andrew Kushnir
parent
1b6b9e283f
commit
68368f6214
@@ -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"
|
<!-- Not using routerLink + fragment because of: https://github.com/angular/angular/issues/30139 -->
|
||||||
[class.docs-toc-item-h3]="item.level === TableOfContentsLevel.H3"
|
<a [href]="location.path() + '#' + item.id" [class.docs-faceted-list-item-active]="item.id === activeItemId()">
|
||||||
>
|
{{ item.title }}
|
||||||
<!-- Not using routerLink + fragment because of: https://github.com/angular/angular/issues/30139 -->
|
</a>
|
||||||
<a
|
</li>
|
||||||
[href]="location.path() + '#' + item.id"
|
|
||||||
[class.docs-faceted-list-item-active]="item.id === activeItemId()"
|
|
||||||
>
|
|
||||||
{{ item.title }}
|
|
||||||
</a>
|
|
||||||
</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() {
|
||||||
this.tableOfContentsLoader.buildTableOfContent(this.contentSourceElement());
|
afterNextRender({
|
||||||
this.scrollSpy.startListeningToScroll(this.contentSourceElement(), this.destroyRef);
|
read: () => {
|
||||||
|
this.tableOfContentsLoader.buildTableOfContent(this.contentSourceElement());
|
||||||
|
this.scrollSpy.setupActiveItemListener(this.contentSourceElement(), this.destroyRef);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToTop(): void {
|
scrollToTop(): void {
|
||||||
|
|||||||
@@ -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",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -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;
|
|
||||||
@@ -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';
|
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user