From 68368f6214d1dbb450d4618ccb37261f4bbb34c6 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Sat, 1 Mar 2025 03:21:33 +0100 Subject: [PATCH] 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 --- adev/shared-docs/BUILD.bazel | 1 - .../table-of-contents.component.html | 32 ++-- .../table-of-contents.component.ts | 13 +- adev/shared-docs/constants/BUILD.bazel | 28 ---- adev/shared-docs/constants/delay.ts | 13 -- adev/shared-docs/constants/index.ts | 9 -- adev/shared-docs/index.ts | 1 - .../interfaces/table-of-contents-item.ts | 2 - adev/shared-docs/services/BUILD.bazel | 1 - .../table-of-contents-loader.service.ts | 58 ++++---- .../table-of-contents-scroll-spy.service.ts | 139 ++---------------- 11 files changed, 59 insertions(+), 238 deletions(-) delete mode 100644 adev/shared-docs/constants/BUILD.bazel delete mode 100644 adev/shared-docs/constants/delay.ts delete mode 100644 adev/shared-docs/constants/index.ts diff --git a/adev/shared-docs/BUILD.bazel b/adev/shared-docs/BUILD.bazel index 845c9629f27..ba820b83e79 100644 --- a/adev/shared-docs/BUILD.bazel +++ b/adev/shared-docs/BUILD.bazel @@ -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", diff --git a/adev/shared-docs/components/table-of-contents/table-of-contents.component.html b/adev/shared-docs/components/table-of-contents/table-of-contents.component.html index e791dd207dd..08540b5e4a4 100644 --- a/adev/shared-docs/components/table-of-contents/table-of-contents.component.html +++ b/adev/shared-docs/components/table-of-contents/table-of-contents.component.html @@ -6,26 +6,18 @@ - @if (shouldDisplayScrollToTop()) { - - } - + + \ No newline at end of file diff --git a/adev/shared-docs/components/table-of-contents/table-of-contents.component.ts b/adev/shared-docs/components/table-of-contents/table-of-contents.component.ts index 1cb0f0bcb25..b01614ed75e 100644 --- a/adev/shared-docs/components/table-of-contents/table-of-contents.component.ts +++ b/adev/shared-docs/components/table-of-contents/table-of-contents.component.ts @@ -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 { diff --git a/adev/shared-docs/constants/BUILD.bazel b/adev/shared-docs/constants/BUILD.bazel deleted file mode 100644 index 5a33d97a870..00000000000 --- a/adev/shared-docs/constants/BUILD.bazel +++ /dev/null @@ -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", - ], - ), -) diff --git a/adev/shared-docs/constants/delay.ts b/adev/shared-docs/constants/delay.ts deleted file mode 100644 index fd62285861c..00000000000 --- a/adev/shared-docs/constants/delay.ts +++ /dev/null @@ -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; diff --git a/adev/shared-docs/constants/index.ts b/adev/shared-docs/constants/index.ts deleted file mode 100644 index 092d17d72e1..00000000000 --- a/adev/shared-docs/constants/index.ts +++ /dev/null @@ -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'; diff --git a/adev/shared-docs/index.ts b/adev/shared-docs/index.ts index 91012a26fb9..9a87abfc7c7 100644 --- a/adev/shared-docs/index.ts +++ b/adev/shared-docs/index.ts @@ -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'; diff --git a/adev/shared-docs/interfaces/table-of-contents-item.ts b/adev/shared-docs/interfaces/table-of-contents-item.ts index 11bf1f256dc..1e0b3b3cd5d 100644 --- a/adev/shared-docs/interfaces/table-of-contents-item.ts +++ b/adev/shared-docs/interfaces/table-of-contents-item.ts @@ -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; } diff --git a/adev/shared-docs/services/BUILD.bazel b/adev/shared-docs/services/BUILD.bazel index f4d0819533e..fec7953c401 100644 --- a/adev/shared-docs/services/BUILD.bazel +++ b/adev/shared-docs/services/BUILD.bazel @@ -25,7 +25,6 @@ ng_module( ], ), deps = [ - "//adev/shared-docs/constants", "//adev/shared-docs/interfaces", "//adev/shared-docs/providers", "//adev/shared-docs/utils", diff --git a/adev/shared-docs/services/table-of-contents-loader.service.ts b/adev/shared-docs/services/table-of-contents-loader.service.ts index 281cc1c5c6f..f526b9d7991 100644 --- a/adev/shared-docs/services/table-of-contents-loader.service.ts +++ b/adev/shared-docs/services/table-of-contents-loader.service.ts @@ -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(); - - 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()); + }); } } diff --git a/adev/shared-docs/services/table-of-contents-scroll-spy.service.ts b/adev/shared-docs/services/table-of-contents-scroll-spy.service.ts index 5f6b990eb4b..42c46516e28 100644 --- a/adev/shared-docs/services/table-of-contents-scroll-spy.service.ts +++ b/adev/shared-docs/services/table-of-contents-scroll-spy.service.ts @@ -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(null); - scrollbarThumbOnTop = signal(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; - } }