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;
- }
}