@redocly/theme 0.1.29 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,8 +42,16 @@ function TableOfContent(props) {
42
42
  var headings = props.headings, tocMaxDepth = props.tocMaxDepth, contentWrapper = props.contentWrapper;
43
43
  var sidebar = (0, react_1.useRef)(null);
44
44
  (0, useFullHeight_1.useFullHeight)(sidebar);
45
- var activeHeadingId = (0, useActiveHeading_1.useActiveHeading)(contentWrapper);
46
45
  var toc = (0, hooks_1.useThemeSettings)(constants_1.DEFAULT_THEME_NAME).toc;
46
+ var getDisplayedHeaderIds = function () {
47
+ if (!headings) {
48
+ return [];
49
+ }
50
+ return headings
51
+ .filter(function (header) { return header && tocMaxDepth >= header.depth; })
52
+ .map(function (header) { return header === null || header === void 0 ? void 0 : header.id; });
53
+ };
54
+ var activeHeadingId = (0, useActiveHeading_1.useActiveHeading)(contentWrapper, getDisplayedHeaderIds());
47
55
  if (toc === null || toc === void 0 ? void 0 : toc.hide) {
48
56
  return null;
49
57
  }
@@ -0,0 +1,15 @@
1
+ export declare class MockIntersectionObserver {
2
+ readonly root: Element | Document | null;
3
+ readonly rootMargin: string;
4
+ readonly thresholds: ReadonlyArray<number>;
5
+ private viewPort;
6
+ private entries;
7
+ private readonly callback;
8
+ constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit);
9
+ private intersect;
10
+ isInViewPort(target: HTMLElement): boolean;
11
+ observe(target: HTMLElement): void;
12
+ unobserve(target: HTMLElement): void;
13
+ disconnect(): void;
14
+ takeRecords(): any[];
15
+ }
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MockIntersectionObserver = void 0;
4
+ var MockIntersectionObserver = /** @class */ (function () {
5
+ function MockIntersectionObserver(callback, options) {
6
+ var _this = this;
7
+ this.intersect = function () {
8
+ _this.entries.map(function (entry) {
9
+ entry.isIntersecting = _this.isInViewPort(entry.target);
10
+ });
11
+ _this.callback(_this.entries, _this);
12
+ };
13
+ this.viewPort = (options === null || options === void 0 ? void 0 : options.root) ? options.root : window;
14
+ this.entries = [];
15
+ this.root = null;
16
+ this.rootMargin = '0px';
17
+ this.thresholds = [1];
18
+ this.callback = callback;
19
+ this.viewPort.addEventListener('scroll', this.intersect);
20
+ }
21
+ MockIntersectionObserver.prototype.isInViewPort = function (target) {
22
+ return target.id !== 'toc-0';
23
+ };
24
+ MockIntersectionObserver.prototype.observe = function (target) {
25
+ this.entries.push({ isIntersecting: false, target: target });
26
+ };
27
+ MockIntersectionObserver.prototype.unobserve = function (target) {
28
+ this.entries = this.entries.filter(function (ob) { return ob.target !== target; });
29
+ };
30
+ MockIntersectionObserver.prototype.disconnect = function () {
31
+ this.viewPort.removeEventListener('scroll', this.intersect);
32
+ this.entries = [];
33
+ };
34
+ MockIntersectionObserver.prototype.takeRecords = function () {
35
+ return this.entries;
36
+ };
37
+ return MockIntersectionObserver;
38
+ }());
39
+ exports.MockIntersectionObserver = MockIntersectionObserver;
@@ -1,2 +1,2 @@
1
1
  export declare type UseActiveHeadingReturnType = string | undefined;
2
- export declare function useActiveHeading(contentElement: Element | null): UseActiveHeadingReturnType;
2
+ export declare function useActiveHeading(contentElement: HTMLDivElement | null, displayedHeaders: Array<string | undefined>): UseActiveHeadingReturnType;
@@ -18,33 +18,82 @@ var __read = (this && this.__read) || function (o, n) {
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.useActiveHeading = void 0;
20
20
  var react_1 = require("react");
21
- function useActiveHeading(contentElement) {
21
+ var react_router_dom_1 = require("react-router-dom");
22
+ function useActiveHeading(contentElement, displayedHeaders) {
22
23
  var _a = __read((0, react_1.useState)(undefined), 2), heading = _a[0], setHeading = _a[1];
23
- var headings = (0, react_1.useMemo)(function () { return contentElement && contentElement.querySelectorAll('.heading-anchor'); }, [contentElement]);
24
- var handler = (0, react_1.useCallback)(
25
- // throttle(
26
- function () {
27
- if (!headings) {
24
+ var _b = __read((0, react_1.useState)([]), 2), headingElements = _b[0], setHeadingElements = _b[1];
25
+ var headingElementsRef = (0, react_1.useRef)({});
26
+ var history = (0, react_router_dom_1.useHistory)();
27
+ var getVisibleHeadings = function () {
28
+ var visibleHeadings = [];
29
+ for (var key in headingElementsRef.current) {
30
+ var headingElement = headingElementsRef.current[key];
31
+ if (headingElement.isIntersecting) {
32
+ visibleHeadings.push(headingElement);
33
+ }
34
+ }
35
+ return visibleHeadings;
36
+ };
37
+ var getIndexFromId = function (id) {
38
+ return headingElements.findIndex(function (item) { return item.id === id; });
39
+ };
40
+ var findHeaders = function (allContent) {
41
+ var allHeaders = allContent.querySelectorAll('.heading-anchor');
42
+ return Array.from(allHeaders);
43
+ };
44
+ var intersectionCallback = function (headings) {
45
+ var _a;
46
+ headingElementsRef.current = headings.reduce(function (map, headingElement) {
47
+ map[headingElement.target.id] = headingElement;
48
+ return map;
49
+ }, headingElementsRef.current);
50
+ var totalHeight = window.scrollY + window.innerHeight;
51
+ // handle bottom of the page
52
+ if (totalHeight >= document.body.scrollHeight) {
53
+ var newHeading = ((_a = headingElements[(headingElements === null || headingElements === void 0 ? void 0 : headingElements.length) - 1]) === null || _a === void 0 ? void 0 : _a.id) || undefined;
54
+ setHeading(newHeading);
28
55
  return;
29
56
  }
30
- for (var i = 0; i < headings.length; i++) {
31
- if (headings[i].offsetTop > window.scrollY) {
32
- var newHeading = i === 0 ? undefined : headings[i - 1].getAttribute('id') || undefined;
33
- setHeading(newHeading);
34
- return;
35
- }
57
+ var visibleHeadings = getVisibleHeadings();
58
+ if (!visibleHeadings.length) {
59
+ return;
60
+ }
61
+ if (visibleHeadings.length === 1) {
62
+ setHeading(visibleHeadings[0].target.id);
63
+ return;
64
+ }
65
+ visibleHeadings.sort(function (a, b) {
66
+ return getIndexFromId(a.target.id) - getIndexFromId(b.target.id);
67
+ });
68
+ setHeading(visibleHeadings[0].target.id);
69
+ };
70
+ (0, react_1.useEffect)(function () {
71
+ if (!contentElement) {
72
+ return;
36
73
  }
37
- }, [headings]);
74
+ setHeadingElements(findHeaders(contentElement));
75
+ var unlisten = history.listen(function () {
76
+ setHeadingElements(findHeaders(contentElement));
77
+ });
78
+ return function () { return unlisten(); };
79
+ }, [contentElement]);
38
80
  (0, react_1.useEffect)(function () {
39
- if (typeof window === 'undefined' || !headings || !headings.length) {
40
- return undefined;
81
+ if (!(headingElements === null || headingElements === void 0 ? void 0 : headingElements.length)) {
82
+ return;
41
83
  }
42
- window.addEventListener('scroll', handler, {
43
- capture: false,
84
+ headingElementsRef.current = {};
85
+ // Bottom rootMargin -30% changes part of the view where IntersectionObserver starts to detect headers
86
+ var observer = new IntersectionObserver(intersectionCallback, {
87
+ rootMargin: '0px 0px -30% 0px',
88
+ threshold: 1,
89
+ });
90
+ headingElements === null || headingElements === void 0 ? void 0 : headingElements.forEach(function (element) {
91
+ if (displayedHeaders.includes(element.id)) {
92
+ observer.observe(element);
93
+ }
44
94
  });
45
- handler();
46
- return function () { return window.removeEventListener('scroll', handler); };
47
- }, [handler, headings]);
95
+ return function () { return observer.disconnect(); };
96
+ }, [headingElements, displayedHeaders]);
48
97
  return heading;
49
98
  }
50
99
  exports.useActiveHeading = useActiveHeading;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redocly/theme",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "Shared UI components",
5
5
  "author": "team@redocly.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -18,9 +18,19 @@ export function TableOfContent(props: TableOfContentProps): JSX.Element | null {
18
18
 
19
19
  const sidebar = useRef<HTMLDivElement | null>(null);
20
20
  useFullHeight(sidebar);
21
- const activeHeadingId = useActiveHeading(contentWrapper);
22
21
  const { toc } = useThemeSettings(DEFAULT_THEME_NAME);
23
22
 
23
+ const getDisplayedHeaderIds = () => {
24
+ if (!headings) {
25
+ return [];
26
+ }
27
+ return headings
28
+ .filter((header) => header && tocMaxDepth >= header.depth)
29
+ .map((header) => header?.id);
30
+ };
31
+
32
+ const activeHeadingId = useActiveHeading(contentWrapper, getDisplayedHeaderIds());
33
+
24
34
  if (toc?.hide) {
25
35
  return null;
26
36
  }
@@ -1,46 +1,110 @@
1
- import { useState, useMemo, useCallback, useEffect } from 'react';
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { useHistory } from 'react-router-dom';
2
3
 
3
4
  export type UseActiveHeadingReturnType = string | undefined;
4
5
 
5
- export function useActiveHeading(contentElement: Element | null): UseActiveHeadingReturnType {
6
+ type HeadingEntry = {
7
+ [key: string]: IntersectionObserverEntry;
8
+ };
9
+
10
+ export function useActiveHeading(
11
+ contentElement: HTMLDivElement | null,
12
+ displayedHeaders: Array<string | undefined>,
13
+ ): UseActiveHeadingReturnType {
6
14
  const [heading, setHeading] = useState<string | undefined>(undefined);
15
+ const [headingElements, setHeadingElements] = useState<HTMLElement[]>([]);
16
+ const headingElementsRef = useRef<HeadingEntry>({});
7
17
 
8
- const headings: NodeListOf<HTMLElement> | null = useMemo(
9
- () => contentElement && contentElement.querySelectorAll<HTMLElement>('.heading-anchor'),
10
- [contentElement],
11
- );
18
+ const history = useHistory();
12
19
 
13
- const handler = useCallback(
14
- // throttle(
15
- () => {
16
- if (!headings) {
17
- return;
18
- }
20
+ const getVisibleHeadings = () => {
21
+ const visibleHeadings: IntersectionObserverEntry[] = [];
22
+
23
+ for (const key in headingElementsRef.current) {
24
+ const headingElement = headingElementsRef.current[key];
19
25
 
20
- for (let i = 0; i < headings.length; i++) {
21
- if (headings[i].offsetTop > window.scrollY) {
22
- const newHeading = i === 0 ? undefined : headings[i - 1].getAttribute('id') || undefined;
23
- setHeading(newHeading);
24
- return;
25
- }
26
+ if (headingElement.isIntersecting) {
27
+ visibleHeadings.push(headingElement);
26
28
  }
27
- },
28
- [headings],
29
- );
29
+ }
30
+
31
+ return visibleHeadings;
32
+ };
33
+
34
+ const getIndexFromId = (id: string) => {
35
+ return headingElements.findIndex((item) => item.id === id);
36
+ };
37
+
38
+ const findHeaders = (allContent: HTMLDivElement) => {
39
+ const allHeaders = allContent.querySelectorAll<HTMLElement>('.heading-anchor');
40
+ return Array.from(allHeaders);
41
+ };
42
+
43
+ const intersectionCallback = (headings: IntersectionObserverEntry[]) => {
44
+ headingElementsRef.current = headings.reduce(
45
+ (map: HeadingEntry, headingElement: IntersectionObserverEntry) => {
46
+ map[headingElement.target.id] = headingElement;
47
+ return map;
48
+ },
49
+ headingElementsRef.current,
50
+ );
51
+
52
+ const totalHeight = window.scrollY + window.innerHeight;
53
+ // handle bottom of the page
54
+ if (totalHeight >= document.body.scrollHeight) {
55
+ const newHeading = headingElements[headingElements?.length - 1]?.id || undefined;
56
+ setHeading(newHeading);
57
+ return;
58
+ }
59
+
60
+ const visibleHeadings = getVisibleHeadings();
61
+ if (!visibleHeadings.length) {
62
+ return;
63
+ }
64
+
65
+ if (visibleHeadings.length === 1) {
66
+ setHeading(visibleHeadings[0].target.id);
67
+ return;
68
+ }
69
+
70
+ visibleHeadings.sort((a, b) => {
71
+ return getIndexFromId(a.target.id) - getIndexFromId(b.target.id);
72
+ });
73
+ setHeading(visibleHeadings[0].target.id);
74
+ };
30
75
 
31
76
  useEffect(() => {
32
- if (typeof window === 'undefined' || !headings || !headings.length) {
33
- return undefined;
77
+ if (!contentElement) {
78
+ return;
34
79
  }
80
+ setHeadingElements(findHeaders(contentElement));
35
81
 
36
- window.addEventListener('scroll', handler, {
37
- capture: false,
82
+ const unlisten = history.listen(() => {
83
+ setHeadingElements(findHeaders(contentElement));
38
84
  });
39
85
 
40
- handler();
86
+ return () => unlisten();
87
+ }, [contentElement]);
88
+
89
+ useEffect(() => {
90
+ if (!headingElements?.length) {
91
+ return;
92
+ }
93
+ headingElementsRef.current = {};
94
+
95
+ // Bottom rootMargin -30% changes part of the view where IntersectionObserver starts to detect headers
96
+ const observer = new IntersectionObserver(intersectionCallback, {
97
+ rootMargin: '0px 0px -30% 0px',
98
+ threshold: 1,
99
+ });
100
+ headingElements?.forEach((element) => {
101
+ if (displayedHeaders.includes(element.id)) {
102
+ observer.observe(element);
103
+ }
104
+ });
41
105
 
42
- return () => window.removeEventListener('scroll', handler);
43
- }, [handler, headings]);
106
+ return () => observer.disconnect();
107
+ }, [headingElements, displayedHeaders]);
44
108
 
45
109
  return heading;
46
110
  }