forked from angular/angular
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathtoc.service.ts
146 lines (122 loc) · 4.94 KB
/
toc.service.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ReplaySubject } from 'rxjs';
import { ScrollSpyInfo, ScrollSpyService } from 'app/shared/scroll-spy.service';
export interface TocItem {
content: SafeHtml;
href: string;
isSecondary?: boolean;
level: string;
title: string;
}
@Injectable()
export class TocService {
tocList = new ReplaySubject<TocItem[]>(1);
activeItemIndex = new ReplaySubject<number | null>(1);
private scrollSpyInfo: ScrollSpyInfo | null = null;
constructor(
@Inject(DOCUMENT) private document: any,
private domSanitizer: DomSanitizer,
private scrollSpyService: ScrollSpyService) { }
genToc(docElement?: Element, docId = '') {
this.resetScrollSpyInfo();
if (!docElement) {
this.tocList.next([]);
return;
}
const headings = this.findTocHeadings(docElement);
const idMap = new Map<string, number>();
const tocList = headings.map(heading => {
const {title, content} = this.extractHeadingSafeHtml(heading);
return {
level: heading.tagName.toLowerCase(),
href: `${docId}#${this.getId(heading, idMap)}`,
title,
content,
};
});
this.tocList.next(tocList);
this.scrollSpyInfo = this.scrollSpyService.spyOn(headings);
this.scrollSpyInfo.active.subscribe(item => this.activeItemIndex.next(item && item.index));
}
reset() {
this.resetScrollSpyInfo();
this.tocList.next([]);
}
// Transform the HTML content to be safe to use in the ToC:
// - Strip off certain auto-generated elements (such as GitHub links and heading anchor links).
// - Strip off any anchor links (but keep their content)
// - Mark the HTML as trusted to be used with `[innerHTML]`.
private extractHeadingSafeHtml(heading: HTMLHeadingElement) {
const div: HTMLDivElement = this.document.createElement('div');
div.innerHTML = heading.innerHTML;
// Remove any `.github-links` or `.header-link` elements (along with their content).
querySelectorAll(div, '.github-links, .header-link').forEach(removeNode);
// Remove any remaining `a` elements (but keep their content).
querySelectorAll(div, 'a').forEach(anchorLink => {
// We want to keep the content of this anchor, so move it into its parent.
const parent = anchorLink.parentNode!;
while (anchorLink.childNodes.length) {
parent.insertBefore(anchorLink.childNodes[0], anchorLink);
}
// Now, remove the anchor.
removeNode(anchorLink);
});
return {
// Security: The document element which provides this heading content is always authored by
// the documentation team and is considered to be safe.
content: this.domSanitizer.bypassSecurityTrustHtml(div.innerHTML.trim()),
title: (div.textContent || '').trim(),
};
}
private findTocHeadings(docElement: Element): HTMLHeadingElement[] {
// const headings = querySelectorAll(docElement, 'h1,h2,h3');
const headings = querySelectorAll<HTMLHeadingElement>(docElement, 'h1,h2,h3');
const skipNoTocHeadings = (heading: HTMLHeadingElement) => !/(?:no-toc|notoc)/i.test(heading.className);
return headings.filter(skipNoTocHeadings);
}
private resetScrollSpyInfo() {
if (this.scrollSpyInfo) {
this.scrollSpyInfo.unspy();
this.scrollSpyInfo = null;
}
this.activeItemIndex.next(null);
}
// Extract the id from the heading; create one if necessary
// Is it possible for a heading to lack an id?
private getId(h: HTMLHeadingElement, idMap: Map<string, number>) {
let id = h.id;
if (id) {
addToMap(id);
} else {
id = (h.textContent || '').trim().toLowerCase().replace(/\W+/g, '-');
id = addToMap(id);
h.id = id;
}
return id;
// Map guards against duplicate id creation.
function addToMap(key: string) {
const oldCount = idMap.get(key) || 0;
const count = oldCount + 1;
idMap.set(key, count);
return count === 1 ? key : `${key}-${count}`;
}
}
}
// Helpers
function querySelectorAll<K extends keyof HTMLElementTagNameMap>(parent: Element, selector: K): HTMLElementTagNameMap[K][];
function querySelectorAll<K extends keyof SVGElementTagNameMap>(parent: Element, selector: K): SVGElementTagNameMap[K][];
function querySelectorAll<E extends Element = Element>(parent: Element, selector: string): E[];
function querySelectorAll(parent: Element, selector: string) {
// Wrap the `NodeList` as a regular `Array` to have access to array methods.
// NOTE: IE11 does not even support some methods of `NodeList`, such as
// [NodeList#forEach()](https://developer.mozilla.org/en-US/docs/Web/API/NodeList/forEach).
return Array.from(parent.querySelectorAll(selector));
}
function removeNode(node: Node): void {
if (node.parentNode !== null) {
// We cannot use `Node.remove()` because of IE11.
node.parentNode.removeChild(node);
}
}