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",
|
||||
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 {
|
||||
|
||||
@@ -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 './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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user